# Webhooks

## What are webhooks

Webhooks provide a powerful mechanism for applications to receive real-time notifications about events occurring in your Optimized.app account. They function as HTTP callbacks that automatically deliver event data to your specified endpoint whenever specific actions take place, such as SMS deliveries, email opens, or voice call completions.

Think of webhooks as digital messengers that automatically notify your systems when something important happens, eliminating the need for constant manual checking or polling for updates.

For example, as a financial institution sending payment reminders, you can use webhooks to automatically update your CRM system the moment a customer opens an email, responds to an SMS, or answers a call. This real-time data flow allows you to track customer engagement across multiple communication channels, optimize your follow-up strategies, and ensure regulatory compliance.

## Get started

To start receiving webhook events in your application:

1. Create a webhook endpoint handler to receive event data POST requests
2. Test your webhook endpoint handler locally
3. Configure your webhook URL in the Optimized.app platform
4. Secure your webhook endpoint with signature verification


You can register one endpoint to handle several different event types simultaneously, or set up individual endpoints for specific events.

## Common use cases

Webhooks are instrumental in creating notifications for specific events. Due to their foundation in HTTP POST, they are straightforward to implement. Some common use cases include:

- **Real-time campaign monitoring**: Receive instant notifications as customers engage with your messages
- **CRM system updates**: Automatically update customer records with the latest interaction data
- **Analytics integration**: Feed communication data into your analytics platform for deeper insights
- **Workflow automation**: Trigger follow-up actions based on customer responses
- **Compliance tracking**: Document all communication attempts for regulatory purposes


Developers have the flexibility to write webhook scripts in various scripting languages, such as Curl, Ruby, Python, PHP, JavaScript, and Go. Once webhook data is captured, it can be stored in a database and leveraged to assess the effectiveness of campaigns or enrich recipient profiles.

## How webhooks work

### Deciding what events to receive

The first phase in preparing for tracking events requires developers to define the specific data they wish to collect. For example, if the goal is to identify particular events, the user URL can execute a script to capture and store relevant information in a local database upon receiving incoming POST requests. This script can also be adapted to gather additional parameters, such as campaign or profile, provided by the webhook.

### List of supported events

table
thead
tr
th
Event
th
Description
tbody
tr
td
code
email_delivered
td
The email has been sent and was successfully received and accepted by the recipient's email server.
tr
td
code
email_bounced
td
The email could not be delivered to the recipient's email server due to permanent issues. These issues may encompass factors such as bounced, or complained addresses, or addresses rejected by an Email Service Provider.
tr
td
code
email_failed
td
The email could not be delivered to the recipient's email server due to permanent issues.
tr
td
code
email_clicked
td
The email recipient has clicked on a link within the email.
tr
td
code
email_opened
td
The email recipient has opened the email.
tr
td
code
sms_delivered
td
The SMS message has been delivered.
tr
td
code
sms_failed
td
The SMS message failed to be delivered.
tr
td
code
voice_call_answered
td
The call was answered.
tr
td
code
voice_call_unanswered
td
The line called was busy or failed to answer.
tr
td
code
voice_call_failed
td
The call failed.
tr
td
code
frequency_rate_limit_reached
td
The contact attempt could not be made because the specified phone number or email address reached the frequency rate limit. The frequency rate limit can be configured at the campaign level to restrict how often a phone number or email address can be contacted within a certain period.
### Create a handler

Set up an HTTP or HTTPS endpoint that can accept webhook requests with a POST method. If you're still developing your endpoint on your local machine, it can use HTTP. After it's deployed to production, your webhook endpoint must use HTTPS.

Set up your endpoint function so that it:

1. Handles POST requests with a JSON payload consisting of an event object
2. Quickly returns a successful status code (`2xx`) prior to any complex logic that might cause a timeout
3. Verifies the webhook signature to ensure the request is coming from Optimized.app
4. Processes the event based on the event type


#### Example endpoint

Node.js/Express

```javascript
const express = require('express');
const bodyParser = require('body-parser');
const crypto = require('crypto');

const app = express();
app.use(bodyParser.json());

// Secret key for webhook verification
const WEBHOOK_SECRET = 'My Super Secret'; // Replace with your actual secret key

// Verify the signature
function verifySignature(payload, signature) {
const calculatedSignature = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(payload.timestamp + payload.data)
    .digest('hex');

return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(calculatedSignature)
);
}

// Webhook endpoint
app.post('/webhook', (req, res) => {
try {
    const { signature, timestamp, data } = req.body;

    if (!signature || !timestamp || !data) {
      console.log('Missing required webhook parameters');
      return res.status(400).send('Bad Request');
    }

    // Verify signature
    if (!verifySignature(req.body, signature)) {
      console.log('Invalid webhook signature');
      return res.status(401).send('Unauthorized');
    }

    // Optional: Verify timestamp to prevent replay attacks
    const currentTime = Date.now() * 1000; // Convert to microseconds
    const timeDifference = currentTime - timestamp;
    // Allow webhooks from the last 5 minutes (300 seconds)
    if (timeDifference > 300000000) { // 5 minutes in microseconds
      console.log('Webhook timestamp too old');
      return res.status(400).send('Bad Request');
    }

    // Parse the data string to JSON
    const parsedData = JSON.parse(data);

    // Process the webhook based on event type
    switch (parsedData.event) {
    case 'email_delivered':
        console.log(`Email delivered to ${parsedData.profile.email_address}`);
        // Add your custom logic here
        break;
    case 'sms_delivered':
        console.log(`SMS delivered to ${parsedData.profile.phone_number}`);
        // Add your custom logic here
        break;
    case 'voice_call_answered':
        console.log(`Voice call answered by ${parsedData.profile.phone_number}`);
        // Add your custom logic here
        break;
    // Add other event types as needed
    default:
        console.log(`Unhandled event type: ${parsedData.event}`);
    }
    
    // Return a success response immediately, before performing any time-consuming tasks
    res.status(200).json({ received: true });
} catch (error) {
    console.error('Webhook processing error:', error);
    res.status(500).json({ error: 'Internal server error' });
}
});

// Start the server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
    console.log(`Webhook server running on port ${PORT}`);
});
```

Python/Flask

```python
from flask import Flask, request, jsonify
import hmac
import hashlib
import json
import time

app = Flask(__name__)

# Secret key for webhook verification
WEBHOOK_SECRET = 'My Super Secret'  # Replace with your actual secret key

# Verify the signature
def verify_signature(payload, signature):
    # Convert timestamp to string if it's not already
    timestamp_str = str(payload['timestamp'])
    data_str = payload['data']
    
    calculated_signature = hmac.new(
        WEBHOOK_SECRET.encode('utf-8'),
        (timestamp_str + data_str).encode('utf-8'),
        hashlib.sha256
    ).hexdigest()
    
    return hmac.compare_digest(signature, calculated_signature)

@app.route('/webhook', methods=['POST'])
def webhook():
    try:
        payload = request.json
        
        signature = payload.get('signature')
        timestamp = payload.get('timestamp')
        data = payload.get('data')
        
        if not signature or not timestamp or not data:
            print('Missing required webhook parameters')
            return 'Bad Request', 400
        
        # Verify signature
        if not verify_signature(payload, signature):
            print('Invalid webhook signature')
            return 'Unauthorized', 401
        
        # Optional: Verify timestamp to prevent replay attacks
        current_time = time.time() * 1000000  # Convert to microseconds
        time_difference = current_time - float(timestamp)
        # Allow webhooks from the last 5 minutes (300 seconds)
        if time_difference > 300000000:  # 5 minutes in microseconds
            print('Webhook timestamp too old')
            return 'Bad Request', 400
        
        # Parse the data string to JSON
        parsed_data = json.loads(data)
        
        # Process the webhook based on event type
        event_type = parsed_data.get('event')
        if event_type == 'email_delivered':
            print(f"Email delivered to {parsed_data['profile']['email_address']}")
            # Add your custom logic here
        elif event_type == 'sms_delivered':
            print(f"SMS delivered to {parsed_data['profile']['phone_number']}")
            # Add your custom logic here
        elif event_type == 'voice_call_answered':
            print(f"Voice call answered by {parsed_data['profile']['phone_number']}")
            # Add your custom logic here
        else:
            print(f"Unhandled event type: {event_type}")
        
        # Return a success response immediately, before performing any time-consuming tasks
        return jsonify({'received': True}), 200
    
    except Exception as e:
        print(f'Webhook processing error: {e}')
        return jsonify({'error': 'Internal server error'}), 500

if __name__ == '__main__':
    PORT = 3000
    app.run(port=PORT)
    print(f"Webhook server running on port {PORT}")
```

### Configuring the URL

Users must provide a designated URL for requests to receive data from a webhook in JSON format. This entails setting up the URL within their application, ensuring it's accessible from the public web while prioritizing security. Webhooks will transmit data to this URL in the "application/json" format.

Here's an example of an HTTP POST made by Optimized.app:


```json
{
  "signature": "bc7b6817e2488718317751251de60b60fd055d297587fe19fb2e9d5e2a36967c",
  "timestamp": 1706099513083629,
  "data": "{\"id\":\"01JVC8K6XDBHG3Q1KYQTTYYBDV\",\"event\":\"email_delivered\",\"group\":{\"uuid\":\"ac7b9988-86ff-4451-91fd-e6c8e2f9ea27\",\"name\":\"Company A\"},\"campaign\":{\"uuid\":\"995e0e81-c79f-41a7-aadc-a113d6087230\",\"name\":\"Basic Campaign\"},\"action\":{\"uuid\":\"995e0e82-06ff-4f7d-85b0-1a39375bb0d9\",\"name\":\"Email Action 1\"},\"profile\":{\"uuid\":\"995e0e82-454c-416c-95a1-f6a726192062\",\"external_id\":\"67482930\"}}"
}
```

> **Note:** Timestamp is in microseconds.


The data includes the following common fields:


```json
{
    "id": "01JVC8K6XDBHG3Q1KYQTTYYBDV",
    "event": "email_delivered",
    "date": "2001-10-21T08:00:00.000000Z",
    "group": {
        "uuid": "ac7b9988-86ff-4451-91fd-e6c8e2f9ea27",
        "name": "Company A"
    },
    "campaign": {
        "uuid": "995e0e81-c79f-41a7-aadc-a113d6087230",
        "name": "Basic Campaign"
    },
    "action": {
        "uuid": "995e0e82-06ff-4f7d-85b0-1a39375bb0d9",
        "name": "Email Action 1"
    },
    "profile": {
        "uuid": "995e0e82-454c-416c-95a1-f6a726192062",
        "external_id": "67482930",
        "first_name": "Jane",
        "last_name": "Doe",
        "email_address": "jane.doe@email.com",
        "phone_number": "+358451234567"
    },
    "profile_tag": {
        "uuid": "754f6305-786f-4ac3-8d7d-7cfa35f594eb",
        "name": "Contact list name"
    },
    "attributes": [
        {
            "name": "HasPaid",
            "value": "Yes"
        }
    ]
}
```

#### Voice call events

The dataset includes additional data for voice call events. It provides information on when the call was initiated, its duration in seconds, the direction of the call (outbound/inbound), status, operator number, attempt, and a answers field that lists the questions asked during the call along with their corresponding answers.


```json
{
    "voice": {
        "initiated_at": "2002-06-22T09:00:00.000000Z",
        "duration": 30,
        "direction": "outbound",
        "status": "answered",
        "operator_number": "+35812345678",
        "attempt": 1,
        "answers": {
            "Consent": "Yes",
            "Bank": "2"
        }
    }
}
```

#### Two-Way SMS events

In Two-Way SMS campaigns, the SMS `sms_delivered` event will also include a question and the answer.


```json
{
    "two_way_sms": {
        "answer": {
            "Consent": "Yes"
        }
    }
}
```

> **Note:** When a Two-Way SMS campaign contains multiple questions, you will receive one SMS event per answered question.


## Ensuring Security

### Verify webhook signatures

To verify that incoming webhook requests are genuinely from Optimized.app and not from a third party, we recommend implementing signature verification. Optimized.app signs webhook events by including a signature in each webhook's payload.

The signature is generated using HMAC-SHA256, with your webhook secret as the key and the concatenation of the timestamp and data as the message. This allows you to verify the authenticity and integrity of each webhook request.

#### Example of verifying the HMAC signature:

JavaScript

```javascript
const crypto = require('crypto');

// Secret key shared between the sender and receiver
const secretKey = 'My Super Secret'; // Replace with your actual secret key
const receivedTimestamp = '1706099513083629' // Replace with the received timestamp
const receivedSignature = 'bc7b6817e2488718317751251de60b60fd055d297587fe19fb2e9d5e2a36967c'; // Replace with the received signature
const receivedData = '{"event":"email_delivered","group":{"uuid":"ac7b9988-86ff-4451-91fd-e6c8e2f9ea27","name":"Company A"},"campaign":{"uuid":"995e0e81-c79f-41a7-aadc-a113d6087230","name":"Basic Campaign"},"action":{"uuid":"995e0e82-06ff-4f7d-85b0-1a39375bb0d9","name":"Email Action 1"},"profile":{"uuid":"995e0e82-454c-416c-95a1-f6a726192062","external_id":"67482930"}}'; // Replace with the received data

// Create an HMAC-SHA256 hash using the secret key
const hmac = crypto.createHmac('sha256', secretKey);

// Update the hash with the received timestamp and received data
hmac.update(receivedTimestamp + receivedData);

// Calculate the HMAC signature
const calculatedSignature = hmac.digest('hex');

// Compare the received signature with the calculated one
if (crypto.timingSafeEqual(Buffer.from(receivedSignature), Buffer.from(calculatedSignature))) {
  console.log('HMAC signature is valid. Data is authentic.');
} else {
  console.log('HMAC signature is invalid. Data may be tampered with.');
}
```

Python

```python
import hmac
import hashlib

# Secret key shared between the sender and receiver
secret_key = 'My Super Secret'  # Replace with your actual secret key
received_timestamp = '1706099513083629'  # Replace with the received timestamp
received_signature = 'bc7b6817e2488718317751251de60b60fd055d297587fe19fb2e9d5e2a36967c'  # Replace with the received signature
received_data = '{"event":"email_delivered","group":{"uuid":"ac7b9988-86ff-4451-91fd-e6c8e2f9ea27","name":"Company A"},"campaign":{"uuid":"995e0e81-c79f-41a7-aadc-a113d6087230","name":"Basic Campaign"},"action":{"uuid":"995e0e82-06ff-4f7d-85b0-1a39375bb0d9","name":"Email Action 1"},"profile":{"uuid":"995e0e82-454c-416c-95a1-f6a726192062","external_id":"67482930"}}'  # Replace with the received data

# Create an HMAC-SHA256 hash using the secret key
calculated_signature = hmac.new(
    secret_key.encode('utf-8'),
    (received_timestamp + received_data).encode('utf-8'),
    hashlib.sha256
).hexdigest()

# Compare the received signature with the calculated one
if hmac.compare_digest(received_signature, calculated_signature):
    print('HMAC signature is valid. Data is authentic.')
else:
    print('HMAC signature is invalid. Data may be tampered with.')
```

PHP

```php
  <?php
  // Secret key shared between the sender and receiver
  $secretKey = 'My Super Secret'; // Replace with your actual secret key
  $receivedTimestamp = '1706099513083629'; // Replace with the received timestamp
  $receivedSignature = 'bc7b6817e2488718317751251de60b60fd055d297587fe19fb2e9d5e2a36967c'; // Replace with the received signature
  $receivedData = '{"event":"email_delivered","group":{"uuid":"ac7b9988-86ff-4451-91fd-e6c8e2f9ea27","name":"Company A"},"campaign":{"uuid":"995e0e81-c79f-41a7-aadc-a113d6087230","name":"Basic Campaign"},"action":{"uuid":"995e0e82-06ff-4f7d-85b0-1a39375bb0d9","name":"Email Action 1"},"profile":{"uuid":"995e0e82-454c-416c-95a1-f6a726192062","external_id":"67482930"}}'; // Replace with the received data

  // Create an HMAC-SHA256 hash using the secret key
  $calculatedSignature = hash_hmac('sha256', $receivedTimestamp . $receivedData, $secretKey);

  // Compare the received signature with the calculated one using a timing-safe comparison
  if (hash_equals($receivedSignature, $calculatedSignature)) {
      echo 'HMAC signature is valid. Data is authentic.';
  } else {
      echo 'HMAC signature is invalid. Data may be tampered with.';
  }
```

Go

```go
package main

import (
  "crypto/hmac"
  "crypto/sha256"
  "encoding/hex"
  "fmt"
)

func main() {
  // Secret key shared between the sender and receiver
  secretKey := "My Super Secret" // Replace with your actual secret key
  receivedTimestamp := "1706099513083629" // Replace with the received timestamp
  receivedSignature := "bc7b6817e2488718317751251de60b60fd055d297587fe19fb2e9d5e2a36967c" // Replace with the received signature
  receivedData := `{"event":"email_delivered","group":{"uuid":"ac7b9988-86ff-4451-91fd-e6c8e2f9ea27","name":"Company A"},"campaign":{"uuid":"995e0e81-c79f-41a7-aadc-a113d6087230","name":"Basic Campaign"},"action":{"uuid":"995e0e82-06ff-4f7d-85b0-1a39375bb0d9","name":"Email Action 1"},"profile":{"uuid":"995e0e82-454c-416c-95a1-f6a726192062","external_id":"67482930"}}` // Replace with the received data

  // Create an HMAC-SHA256 hash using the secret key
  h := hmac.New(sha256.New, []byte(secretKey))
  
  // Update the hash with the received timestamp and received data
  dataToSign := receivedTimestamp + receivedData
  h.Write([]byte(dataToSign))
  
  // Calculate the HMAC signature as bytes
  calculatedSignatureBytes := h.Sum(nil)
  
  // Convert the received signature from hex to bytes for comparison
  receivedSignatureBytes, err := hex.DecodeString(receivedSignature)
  if err != nil {
    fmt.Println("Error decoding received signature:", err)
    return
  }
  
  // Compare the received signature with the calculated one using a timing-safe comparison
  if hmac.Equal(receivedSignatureBytes, calculatedSignatureBytes) {
    fmt.Println("HMAC signature is valid. Data is authentic.")
  } else {
    fmt.Println("HMAC signature is invalid. Data may be tampered with.")
  }
}
```

### Preventing replay attacks

A replay attack occurs when an attacker intercepts a valid webhook request and its signature, then re-transmits them. To mitigate such attacks:

1. Verify the webhook signature as shown above
2. Check that the timestamp in the request is recent (typically within 5 minutes)
3. Maintain a log of processed webhook IDs to prevent processing duplicates


### Ensure your endpoint is publicly accessible

Your webhook endpoint needs to be publicly accessible for Optimized.app to deliver events. For production usage, ensure:

1. Your endpoint uses HTTPS with a valid SSL certificate
2. Your server is configured to accept POST requests
3. Any firewalls or security rules allow incoming connections from Optimized.app


## Testing your webhooks

Before going live with your webhook implementation, we recommend thorough testing:

1. Set up a local development environment with your webhook handler
2. Use a tool like [ngrok](https://ngrok.com/) to create a temporary public URL for your local endpoint
3. Configure this temporary URL in Optimized.app's webhook settings
4. Trigger events from your Optimized.app account
5. Monitor the requests in your local environment to ensure proper handling


## Best Practices

### Handle events asynchronously

For optimal performance, process webhook events asynchronously:

1. Quickly return a 200 success response before performing any time-consuming operations
2. Queue the event for background processing
3. Process the event from the queue at a rate your system can support


This approach prevents timeouts and ensures your webhook endpoint remains responsive, even during high volumes of incoming webhooks.


```javascript
// Example of asynchronous processing
app.post('/webhook', (req, res) => {
  try {
    const { signature, timestamp, data } = req.body;
    
    // Verify signature
    if (!verifySignature(req.body, signature)) {
      return res.status(401).send('Unauthorized');
    }
    
    // Return 200 immediately
    res.status(200).json({ received: true });
    
    // Process the event asynchronously
    processEventAsync(JSON.parse(data)).catch(err => {
      console.error('Error processing event:', err);
    });
  } catch (error) {
    console.error('Webhook error:', error);
    res.status(500).json({ error: 'Internal server error' });
  }
});

async function processEventAsync(eventData) {
  // Time-consuming operations here
  // Store in database, update CRM, etc.
}
```

### Handle duplicate events

Webhook endpoints might occasionally receive the same event more than once due to retries or other delivery mechanisms. To prevent processing duplicates:

1. Store processed event IDs in a database or cache
2. Check if an incoming event ID has already been processed before handling it



```javascript
async function isEventProcessed(eventId) {
  // Check if the event has already been processed
  const result = await db.query('SELECT 1 FROM processed_events WHERE event_id = ?', [eventId]);
  return result.length > 0;
}

async function markEventAsProcessed(eventId) {
  // Mark the event as processed
  await db.query('INSERT INTO processed_events (event_id, processed_at) VALUES (?, NOW())', [eventId]);
}

app.post('/webhook', async (req, res) => {
  try {
    // Verify signature, etc.
    
    const eventData = JSON.parse(req.body.data);
    const eventId = eventData.id;
    
    // Check for duplicates
    if (await isEventProcessed(eventId)) {
      return res.status(200).json({ received: true, duplicate: true });
    }
    
    // Return 200 immediately
    res.status(200).json({ received: true });
    
    // Process event and mark as processed
    await processEventAsync(eventData);
    await markEventAsProcessed(eventId);
  } catch (error) {
    console.error('Webhook error:', error);
    res.status(500).json({ error: 'Internal server error' });
  }
});
```

### Implement proper error handling

Robust error handling ensures your webhook endpoint can gracefully handle unexpected situations:

1. Use try-catch blocks to capture and log errors
2. Return appropriate HTTP status codes for different error types
3. Implement comprehensive logging for debugging purposes


### Exempt webhook routes from CSRF protection

If you're using a web framework that implements CSRF protection, you'll need to exempt your webhook routes:

Django

```python
# Django example
@csrf_exempt
def webhook_handler(request):
    # Process webhook
```

Rails

```ruby
# Rails example
class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token, only: [:handle]
  
  def handle
    # Process webhook
  end
end
```

Laravel

```php
// For Laravel 11+
// In app/Providers/RouteServiceProvider.php
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;

// In boot() method
$this->app->singleton(VerifyCsrfToken::class, function () {
    return new VerifyCsrfToken([
        'webhook/*',
        'api/stripe/webhook',
        // other routes to exclude
    ]);
});

// For Laravel 10 and earlier
// In app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
    'webhook/*',
    'api/stripe/webhook',
    // other routes to exclude
];
```

## Troubleshooting

### Common issues and solutions

| Issue | Possible Causes | Solutions |
|  --- | --- | --- |
| Not receiving webhooks | Endpoint not publicly accessibleIncorrect URL configured | Verify your endpoint is accessible from the internetCheck webhook configuration in Optimized.app |
| Invalid signature errors | Incorrect secret keyModified payload | Confirm you're using the correct webhook secretEnsure the payload isn't modified before verification |
| Duplicate event processing | Retries from Optimized.appMissing duplicate detection | Implement idempotent event processingStore and check event IDs before processing |
| Timeouts | Long-running operations in webhook handler | Return 200 immediately, then process asynchronously |


## Next Steps

- Configure your webhook endpoints in the Optimized.app under **Organization Settings → Integration → Webhooks**


![Organizational Settings Image](/assets/org_settings.78192b8bc8d2799e647cd91734dfa8426dfa6f64ef4c0ff6fdc5410ba1b0cf80.9c1bb791.jpg)

*The Organization Settings → Integration screen, showing where to create a new Webhooks*

- Test your webhook implementation with events before going live
- Implement proper error handling and logging in your webhook scripts
- Consider implementing a queue system for processing webhook data if you expect high volumes


## Need help?

If you encounter any issues with webhook integration, please contact Optimized.app support at support@ontime.fi.