Skip to content

Webhooks

Warning

Webhooks are currently in BETA testing.

A webhook is a method of communication between two systems that enables real-time, event-driven data transfer. It allows one application to automatically notify another when a specific event occurs, such as a Brand having their status changed. Unlike traditional APIs, which require frequent polling to check for updates, webhooks push data to a designated URL via an HTTP POST request as soon as an event happens, making them more efficient and timely.

Getting Started

Creating your first webhook

Within our Partner Hub, we have a handy UI to allow you to manage your webhooks which is available within the API Admin section. Here you can create your first webhook.

Webhook Create Modal

INFO

When you create your first webhook, it will be disabled by default. You can enable it on the list view using the Status slider.

Webhook Name: A friendly name for your webhook.

Webhook URL: The endpoint to deliver the webhook notifications to. This must start with https:// and we will verify the endpoint has a valid SSL Certificate when attempting to deliver a webhook notification.

Selected events: The list of available events you can add to this endpoint. You can select more than per endpoint.

Testing Webhooks

You can fire test events to your endpoint by using the Test button on the list view. In the modal that shows up, you can select a status as well as one or more Brands to use.

Webhook Test Modal

INFO

When you fire test events to your endpoint, the webhook-id header will be prefixed with test_ instead of msg_. You can only fire 10 test webhook notification per minute.

Webhook Structure

Headers

webhook-id string: This is a unique identifier for each webhook event or request. It allows the receiving system to track or log events and helps ensure no duplicate handling of the same event. When firing test events from our Partner Hub, this will be prefixed with test_ instead of msg_.

webhook-timestamp int: This header indicates the exact time when the webhook event was generated. It helps the receiver validate the event's freshness and ensure the request hasn't expired or been delayed excessively. When attempting retry requests, this will be regenerated.

webhook-signature string: The signature is typically a hashed value, created by Tillo, when preparing to send the webhook to your specified endpoint.

These headers collectively help ensure secure, authentic, and reliable webhook processing.

Payload Structure

All webhooks that we provide will follow the same basic webhook payload structure to ensure requests are consistent across different webhooks. For all webhooks you receive, regardless of the type, the overall structure will be the same.

json
{
  "type": "brands.status.updated",
  "timestamp": "2024-10-23T15:16:17Z",
  "certificate": "-----BEGIN CERTIFICATE-----\r\n...\r\n-----END CERTIFICATE-----",
  "version": 1,
  "data": { 
    
  }
}
{
  "type": "brands.status.updated",
  "timestamp": "2024-10-23T15:16:17Z",
  "certificate": "-----BEGIN CERTIFICATE-----\r\n...\r\n-----END CERTIFICATE-----",
  "version": 1,
  "data": { 
    
  }
}

type string: A full-stop delimited type associated with the event. The type indicates the type of the event being sent (e.g "user.created" or "invoice.paid"), indicates the schema of the payload (passed in data), and it should be grouped hierarchically. The available event types can be seen within Available Webhooks.

timestamp string: The timestamp of when the event occurred (not necessarily the same as when it was delivered).

certificate string: The certificate you can use to verify the authenticity of the webhook notification. You can read more about this in the Security Recommendations.

version int: The current version of the webhook.

data object: The actual event data associated with the event. The structure of this object will be different for each webhook event type.

Response Handling

When building your integration with webhooks, it's essential that you handle the response correctly and return the necessary status codes. This ensures that the end-to-end process of attempting to deliver and subsequently retrying the request upon failure is handled properly.

Status Codes

Your endpoint should return an HTTP status code in the 2xx range (e.g. 200, 201, 202) to indicate successful receipt of the webhook. Any other status code will be considered a failure and will trigger our retry mechanism.

We recommend processing webhooks asynchronously and returning a 202 Accepted response quickly, rather than processing the webhook synchronously which could lead to timeout issues.

Response Times & Retries

When attempting to deliver a webhook notification, we will wait up to 10 seconds for a response from your server. If you exceed the allowed wait time we will then push the webhook notification back to our queue and attempt to deliver it up to 3 times, with each attempt waiting 10 seconds.

If your server returns a HTTP status code in the 5xx range, we will automatically mark the webhook notification as failed and our system will not attempt to deliver it again.

Exponential Backoff

After each failed attempt at delivering the webhook notification, we will gradually delay next attempt at delivering the notification. We are in the process of fine-tuning the backoff strategy, however the following is the current configuration.

AttemptDelay (seconds)
1-
210
3100

Available Webhooks

Brand Status Changes brands.status.updated

This webhook provides real-time updates when a Brands status gets updated, whether that is ENABLED, DISABLED, or PAUSED. See Brand Information for the meaning behind each status. This will allow you to automate some of the processes behind the scenes when a status has changed instead of only polling the Brand Information Endpoint on our v2 API every 24 hours.

Example Payload Structures

Enabled Brand
json5
{
  "type": "brands.status.updated",
  "timestamp": "2024-08-01T09:30:35Z",
  "certificate": "-----BEGIN CERTIFICATE-----\r\n...\r\n-----END CERTIFICATE-----",
  "version": 1,
  "data": [ 
    {
      "name": "Example Brand",
      "slug": "example-brand",
      "status": {
        "code": "ENABLED"
      }
    }
  ]
}
{
  "type": "brands.status.updated",
  "timestamp": "2024-08-01T09:30:35Z",
  "certificate": "-----BEGIN CERTIFICATE-----\r\n...\r\n-----END CERTIFICATE-----",
  "version": 1,
  "data": [ 
    {
      "name": "Example Brand",
      "slug": "example-brand",
      "status": {
        "code": "ENABLED"
      }
    }
  ]
}
Multiple Brands Enabled
json5
{
  "type": "brands.status.updated",
  "timestamp": "2024-08-01T09:30:35Z",
  "certificate": "-----BEGIN CERTIFICATE-----\r\n...\r\n-----END CERTIFICATE-----",
  "version": 1,
  "data": [ 
    {
      "name": "Example Brand",
      "slug": "example-brand",
      "status": {
        "code": "ENABLED"
      }
    }, 
    {
      "name": "Example Brand 2",
      "slug": "example-brand-2",
      "status": {
        "code": "ENABLED"
      }
    }
  ]
}
{
  "type": "brands.status.updated",
  "timestamp": "2024-08-01T09:30:35Z",
  "certificate": "-----BEGIN CERTIFICATE-----\r\n...\r\n-----END CERTIFICATE-----",
  "version": 1,
  "data": [ 
    {
      "name": "Example Brand",
      "slug": "example-brand",
      "status": {
        "code": "ENABLED"
      }
    }, 
    {
      "name": "Example Brand 2",
      "slug": "example-brand-2",
      "status": {
        "code": "ENABLED"
      }
    }
  ]
}
Disabled Brand
json5
{
  "type": "brands.status.updated",
  "timestamp": "2024-08-01T09:30:35Z",
  "certificate": "-----BEGIN CERTIFICATE-----\r\n...\r\n-----END CERTIFICATE-----",
  "version": 1,
  "data": [  
    {
      "name": "Example Brand",
      "slug": "example-brand",
      "status": {
        "code": "DISABLED",
        "reason": "The Brand is inactive"
      }
    }
  ]
}
{
  "type": "brands.status.updated",
  "timestamp": "2024-08-01T09:30:35Z",
  "certificate": "-----BEGIN CERTIFICATE-----\r\n...\r\n-----END CERTIFICATE-----",
  "version": 1,
  "data": [  
    {
      "name": "Example Brand",
      "slug": "example-brand",
      "status": {
        "code": "DISABLED",
        "reason": "The Brand is inactive"
      }
    }
  ]
}
Multiple Brands Disabled
json5
{
  "type": "brands.status.updated",
  "timestamp": "2024-08-01T09:30:35Z",
  "certificate": "-----BEGIN CERTIFICATE-----\r\n...\r\n-----END CERTIFICATE-----",
  "version": 1,
  "data": [ 
    {
      "name": "Example Brand",
      "slug": "example-brand",
      "status": {
        "code": "DISABLED",
        "reason": "The Brand is inactive"
      }
    },
    {
      "name": "Example Brand 2",
      "slug": "example-brand-2",
      "status": {
        "code": "DISABLED",
        "reason": "The Brand is inactive"
      }
    }
  ]
}
{
  "type": "brands.status.updated",
  "timestamp": "2024-08-01T09:30:35Z",
  "certificate": "-----BEGIN CERTIFICATE-----\r\n...\r\n-----END CERTIFICATE-----",
  "version": 1,
  "data": [ 
    {
      "name": "Example Brand",
      "slug": "example-brand",
      "status": {
        "code": "DISABLED",
        "reason": "The Brand is inactive"
      }
    },
    {
      "name": "Example Brand 2",
      "slug": "example-brand-2",
      "status": {
        "code": "DISABLED",
        "reason": "The Brand is inactive"
      }
    }
  ]
}
Paused Brand
json5
{
  "type": "brands.status.updated",
  "timestamp": "2024-08-01T09:30:35Z",
  "certificate": "-----BEGIN CERTIFICATE-----\r\n...\r\n-----END CERTIFICATE-----",
  "version": 1,
  "data": [  
    {
      "name": "Example Brand",
      "slug": "example-brand",
      "status": {
        "code": "PAUSED",
        "reason": "The Brand is inactive"
      }
    }
  ]
}
{
  "type": "brands.status.updated",
  "timestamp": "2024-08-01T09:30:35Z",
  "certificate": "-----BEGIN CERTIFICATE-----\r\n...\r\n-----END CERTIFICATE-----",
  "version": 1,
  "data": [  
    {
      "name": "Example Brand",
      "slug": "example-brand",
      "status": {
        "code": "PAUSED",
        "reason": "The Brand is inactive"
      }
    }
  ]
}
Multiple Brands Paused
json5
{
  "type": "brands.status.updated",
  "timestamp": "2024-08-01T09:30:35Z",
  "certificate": "-----BEGIN CERTIFICATE-----\r\n...\r\n-----END CERTIFICATE-----",
  "version": 1,
  "data": [ 
    {
      "name": "Example Brand",
      "slug": "example-brand",
      "status": {
        "code": "PAUSED",
        "reason": "The Brand is inactive"
      }
    },
    {
      "name": "Example Brand 2",
      "slug": "example-brand-2",
      "status": {
        "code": "PAUSED",
        "reason": "The Brand is inactive"
      }
    }
  ]
}
{
  "type": "brands.status.updated",
  "timestamp": "2024-08-01T09:30:35Z",
  "certificate": "-----BEGIN CERTIFICATE-----\r\n...\r\n-----END CERTIFICATE-----",
  "version": 1,
  "data": [ 
    {
      "name": "Example Brand",
      "slug": "example-brand",
      "status": {
        "code": "PAUSED",
        "reason": "The Brand is inactive"
      }
    },
    {
      "name": "Example Brand 2",
      "slug": "example-brand-2",
      "status": {
        "code": "PAUSED",
        "reason": "The Brand is inactive"
      }
    }
  ]
}

The data property will always be an array, even if there is only 1 Brand contained, to ensure consistency whether it's just 1 or many Brands being updated.

Brand Denomination Changes brands.denominations.updated

For brands that use a fixed denomination, this webhook provides real-time notifications when a denomination is changed. This will be triggered whenever we add or remove a denomination on one of your brands and can be useful to prevent issuance issues when a particular denomination becomes temporarily unavailable.

Example Payload Structures

Brand Denomination Updated
json5
{
  "type": "brands.denominations.updated",
  "timestamp": "2025-01-01T00:00:00Z",
  "certificate": "-----BEGIN CERTIFICATE-----\r\n ... r\n-----END CERTIFICATE-----",
  "version": 1,
  "data": {
    "name": "Example brand",
    "slug": "example-brand",
    "denominations": [
      "10.00",
      "15.00",
      "20.00",
      "25.00",
      "30.00",
      "39.00",
      "40.00",
      "50.00",
      "75.00",
      "100.00"
    ]
  }
}
{
  "type": "brands.denominations.updated",
  "timestamp": "2025-01-01T00:00:00Z",
  "certificate": "-----BEGIN CERTIFICATE-----\r\n ... r\n-----END CERTIFICATE-----",
  "version": 1,
  "data": {
    "name": "Example brand",
    "slug": "example-brand",
    "denominations": [
      "10.00",
      "15.00",
      "20.00",
      "25.00",
      "30.00",
      "39.00",
      "40.00",
      "50.00",
      "75.00",
      "100.00"
    ]
  }
}

NOTE

denominations will contain a list of all available denominations for that brand formatted as a string to two decimal places.

Float Alert Below floats.alert.below

This webhook is sent when a float's balance is below a certain threshold. There are two types of thresholds, funds and days. Both thresholds can be configured and triggered simultaneously.

The funds threshold is the amount of funds that must be available before the alert is triggered. This is actioned when the float's balance is below or equal to the specified value.

The days threshold (aka periodic alert) is the number of days before the float's balance reaches zero. This is actioned when the estimated number of days remaining on the float is less or equal to the threshold.

NOTE

See this article for details on how to set up alerts for your floats: Set Up Fund Alerts for Your Floats

Example Payload Structures

Funds Threshold
json5
{
  "type": "float.alert.below",
  "timestamp": "2024-10-23T15:16:17Z",
  "certificate": "-----BEGIN CERTIFICATE-----\r\n...\r\n-----END CERTIFICATE-----",
  "version": 1,
  "data": { 
    "float": "universal-float",
    "available_balance": {
          "amount": 999.49,
          "currency": "GBP"
      },
      "pending_payments": {
          "amount": 100.00,
          "currency": "GBP"
      },
      "trigger_settings": {
          "type": "funds",
          "threshold": 1000,
          "message": "below {userDefinedBalance}"
      }
  }
}
{
  "type": "float.alert.below",
  "timestamp": "2024-10-23T15:16:17Z",
  "certificate": "-----BEGIN CERTIFICATE-----\r\n...\r\n-----END CERTIFICATE-----",
  "version": 1,
  "data": { 
    "float": "universal-float",
    "available_balance": {
          "amount": 999.49,
          "currency": "GBP"
      },
      "pending_payments": {
          "amount": 100.00,
          "currency": "GBP"
      },
      "trigger_settings": {
          "type": "funds",
          "threshold": 1000,
          "message": "below {userDefinedBalance}"
      }
  }
}
Days Threshold
json5
{
  "type": "float.alert.below",
  "timestamp": "2024-10-23T15:16:17Z",
  "certificate": "-----BEGIN CERTIFICATE-----\r\n...\r\n-----END CERTIFICATE-----",
  "version": 1,
  "data": { 
    "float": "universal-float",
    "available_balance": {
          "amount": 19999.49,
          "currency": "GBP"
      },
      "pending_payments": {
          "amount": 0.00,
          "currency": "GBP"
      },
      "trigger_settings": {
          "type": "days",
          "threshold": 30,
          "message": "below {userDefinedBalance}"
      }
  }
}
{
  "type": "float.alert.below",
  "timestamp": "2024-10-23T15:16:17Z",
  "certificate": "-----BEGIN CERTIFICATE-----\r\n...\r\n-----END CERTIFICATE-----",
  "version": 1,
  "data": { 
    "float": "universal-float",
    "available_balance": {
          "amount": 19999.49,
          "currency": "GBP"
      },
      "pending_payments": {
          "amount": 0.00,
          "currency": "GBP"
      },
      "trigger_settings": {
          "type": "days",
          "threshold": 30,
          "message": "below {userDefinedBalance}"
      }
  }
}

Float Alert Resolved floats.alert.resolved

This webhook is triggered when a float's alert is resolved. See Float Alert Below for more details.

Example Payload Structure

Funds Threshold
json5
{
  "type": "float.alert.resolved",
  "timestamp": "2024-10-23T15:16:17Z",
  "certificate": "-----BEGIN CERTIFICATE-----\r\n...\r\n-----END CERTIFICATE-----",
  "version": 1,
  "data": { 
    "float": "universal-float",
    "available_balance": {
          "amount": 30000.49,
          "currency": "GBP"
      },
      "pending_payments": {
          "amount": 100.00,
          "currency": "GBP"
      },
      "trigger_settings": {
          "type": "funds",
          "threshold": 19999.49,
          "message": "above {userDefinedBalance}"
      }
  }
}
{
  "type": "float.alert.resolved",
  "timestamp": "2024-10-23T15:16:17Z",
  "certificate": "-----BEGIN CERTIFICATE-----\r\n...\r\n-----END CERTIFICATE-----",
  "version": 1,
  "data": { 
    "float": "universal-float",
    "available_balance": {
          "amount": 30000.49,
          "currency": "GBP"
      },
      "pending_payments": {
          "amount": 100.00,
          "currency": "GBP"
      },
      "trigger_settings": {
          "type": "funds",
          "threshold": 19999.49,
          "message": "above {userDefinedBalance}"
      }
  }
}
Days Threshold
json5
{
  "type": "float.alert.resolved",
  "timestamp": "2024-10-23T15:16:17Z",
  "certificate": "-----BEGIN CERTIFICATE-----\r\n...\r\n-----END CERTIFICATE-----",
  "version": 1,
  "data": { 
    "float": "universal-float",
    "available_balance": {
          "amount": 1999.49,
          "currency": "GBP"
      },
      "pending_payments": {
          "amount": 200.00,
          "currency": "GBP"
      },
      "trigger_settings": {
          "type": "days",
          "threshold": 30,
          "message": "above {userDefinedBalance}"
      }
  }
}
{
  "type": "float.alert.resolved",
  "timestamp": "2024-10-23T15:16:17Z",
  "certificate": "-----BEGIN CERTIFICATE-----\r\n...\r\n-----END CERTIFICATE-----",
  "version": 1,
  "data": { 
    "float": "universal-float",
    "available_balance": {
          "amount": 1999.49,
          "currency": "GBP"
      },
      "pending_payments": {
          "amount": 200.00,
          "currency": "GBP"
      },
      "trigger_settings": {
          "type": "days",
          "threshold": 30,
          "message": "above {userDefinedBalance}"
      }
  }
}

Security Recommendations

Verifying the signature

To ensure that the data contained within the webhook has not been tampered with you will need to verify the signature using the public key which you can extract from the provided certificate.

Each webhook has a webhook-signature header which contains a base64 encoded signature, prefixed with v1a (which is the standard signature identifier when using asymmetric signatures). Signatures are signed using the PSS (Probabilistic Signature Scheme).

When using this signature to verify the contents, ensure that you:

  • Remove the v1a, prefix from the signature
  • Base64 decode the remainder of the string

We are using the signature scheme recommended by the webhook specification when signing content which is a concatenated string (delimited by full stops) made up of three parts:

  • The webhook-id header
  • The webhook-timestamp header
  • The entire body of the payload (the body will always be a json encoded string)

So, for example, if we had the following webhook:

--header 'webhook-id: msg_b5c4249c41094edd9f3c2db0d383f827' 
--header 'webhook-timestamp: 1730197224'
--header 'webhook-signature: v1a,VHVy2TY0FNUdXP3ox+LqTjmmD6nlQGZb0miZNcdJ0qulMqlyQO2cvCVOowKuVH9ATrs7bWAlK5NjWWx3hKyaOBqcuQgAfc1sEykVUg1+KHK3GSrJtuoJXYS6qy3QIBY1NqXQ\/LrOUcM97w8XiAz7ucrxLujySpCDwMh1Z8zZofPO\/E9vNginbud\/MbXpr0JNd9FCx5TNxIxKaXXlvPd3+io5\/3fYb5ihu7adQyicGbq59n3jGc5738Kdu8iDBEtW2ink8JOg+WI\/20R3BKCFaFBJQMHqrAETsTj4Ew3LDsvnU3W1QRceednq2Y6pjA9DB5VcQmC+m8xa4vh0AFqSLIBUypgggC8u7QyKi35MM5WwykvnLjArDLPR25eIKYN6AOf\/ckMbngLstZDZdytrLvBvQfVKU34O7sXFE6PpFdemEaQIWPrvd\/ZFNgOsCzzZanvQwUj8K1oJ4kRe9RjHM+lQ0AiJynbsszonej1jo7Sdp9RgCQOUYATgGBcFNBYdB+x6CpSXwyGJ+CAcyZZZoN0xcrpF+wb8B+vfJ8lv+ahGQp1+iXYhVIZ6hTZMffnuP0NQwuj9fBJMPaaFz62uWowS9xUG3FDkcx7y5p8zo13TZHpO3T1WAu0bc+UVIRsnZh2F9fyuoxHZWPA7N+8t1NyMjW1OtTw5XrBCSpG0\/qs=' 
--data '{
    "type": "example.event.type.status.updated",
    "timestamp": "2024-10-29T10:20:21Z",
    "certificate": "-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----",
    "version": 1,
    "data": [
        {
            "shortened-payload": "test"
        }
    ]
}'
--header 'webhook-id: msg_b5c4249c41094edd9f3c2db0d383f827' 
--header 'webhook-timestamp: 1730197224'
--header 'webhook-signature: v1a,VHVy2TY0FNUdXP3ox+LqTjmmD6nlQGZb0miZNcdJ0qulMqlyQO2cvCVOowKuVH9ATrs7bWAlK5NjWWx3hKyaOBqcuQgAfc1sEykVUg1+KHK3GSrJtuoJXYS6qy3QIBY1NqXQ\/LrOUcM97w8XiAz7ucrxLujySpCDwMh1Z8zZofPO\/E9vNginbud\/MbXpr0JNd9FCx5TNxIxKaXXlvPd3+io5\/3fYb5ihu7adQyicGbq59n3jGc5738Kdu8iDBEtW2ink8JOg+WI\/20R3BKCFaFBJQMHqrAETsTj4Ew3LDsvnU3W1QRceednq2Y6pjA9DB5VcQmC+m8xa4vh0AFqSLIBUypgggC8u7QyKi35MM5WwykvnLjArDLPR25eIKYN6AOf\/ckMbngLstZDZdytrLvBvQfVKU34O7sXFE6PpFdemEaQIWPrvd\/ZFNgOsCzzZanvQwUj8K1oJ4kRe9RjHM+lQ0AiJynbsszonej1jo7Sdp9RgCQOUYATgGBcFNBYdB+x6CpSXwyGJ+CAcyZZZoN0xcrpF+wb8B+vfJ8lv+ahGQp1+iXYhVIZ6hTZMffnuP0NQwuj9fBJMPaaFz62uWowS9xUG3FDkcx7y5p8zo13TZHpO3T1WAu0bc+UVIRsnZh2F9fyuoxHZWPA7N+8t1NyMjW1OtTw5XrBCSpG0\/qs=' 
--data '{
    "type": "example.event.type.status.updated",
    "timestamp": "2024-10-29T10:20:21Z",
    "certificate": "-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----",
    "version": 1,
    "data": [
        {
            "shortened-payload": "test"
        }
    ]
}'

We would first need to concatenate together the three parts ("webhook-id.webhook-timestamp.body")

Which should look something like this:

"msg_b5c4249c41094edd9f3c2db0d383f827.1730197224.{ "type": "example.event.type.status.updated", "timestamp": "2024-10-29T10:20:21Z", "certificate": "-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----", "version": 1, "data": [ { "shortened-payload": "test" } ] }

You can then use the public key extracted from the certificate to verify that the content matches the signature.

Certificate

Our CA root certificate is available by sending a GET request to https://ca.tillo.io. You should use this to verify the authenticity of the certificate provided in the webhook payload you have received.

You can utilise the provided code examples below to validate against the certificate provided by Tillo.

Verifying a Webhook

Warning

When building your integration, we highly recommend that you verify all webhook notifications you receive to your endpoint.

Verifying the authenticity of a webhook is crucial for ensuring security and trust between systems. It confirms that the request truly originates from the expected source and hasn't been tampered with during transmission. This prevents malicious actors from sending fake or altered webhooks that could trigger unauthorized actions, compromise data integrity, or overwhelm your system with fraudulent requests.

By authenticating webhooks, you protect your application from spoofing, data tampering, and potential denial-of-service (DoS) attacks. This helps ensure that only legitimate events drive actions within your system, maintaining the integrity of your business processes and, in some cases, fulfilling regulatory requirements for secure data exchange.

Tillo CA

We have provided a basic code example on how to achieve this in Python below.

INFO

While this code will download the certificate on every request, we recommend storing a temporary local copy to prevent unnecessary network requests. We also recommend that you check the CRL (Certificate Revocation List) on every webhook notification as this could be updated at any time.

php

<?php

use phpseclib3\Crypt\RSA;
use phpseclib3\File\X509;

// Constants
const CA_CERT_URL = "https://ca.tillo.io/";

function decodeBase64(string $data, string $prefix = 'v1a,'): string
{
	return base64_decode(ltrim($data, $prefix));
}

function fetchCaCert(): ?X509
{
	$caCertPem = file_get_contents(CA_CERT_URL);
	if (!$caCertPem) {
		error_log("[Error] Failed to load CA certificate from URL.");
		return null;
	}

	$caCert = new X509();
	return $caCert->loadX509($caCertPem) ? $caCert : null;
}

function verifySignature(X509 $signingCert, array $headers, string $payloadData): bool
{
	try {
		$signature = decodeBase64($headers['webhook-signature']);
		$data = $headers['webhook-id'] . '.' . $headers['webhook-timestamp'] . '.' .  $payloadData;

		return RSA::loadPublicKey($signingCert->getPublicKey())->verify($data, $signature);
	} catch (Exception $e) {
		error_log("[Error] Signature verification failed: " . $e->getMessage());
		return false;
	}
}

function verifyCertAgainstCA(X509 $signingCert, X509 $caCert): bool
{
	try {
		$caCertPem = $caCert->saveX509($caCert->getCurrentCert()); // Get the PEM-encoded string of the CA certificate
		$signingCertPem = $signingCert->saveX509($signingCert->getCurrentCert()); // Get the PEM-encoded string of the CA certificate

		$x509 = new X509();
		$x509->loadCA($caCertPem);
		$x509->loadX509($signingCertPem);
		$result = $x509->validateSignature();

		return $result === true;
	} catch (Exception $e) {
		error_log("[Error] Signing certificate verification against CA failed: " . $e->getMessage());
		return false;
	}
}
function getCrlUrlFromCert(X509 $signingCert): ?string {
	$crlDistributionPoints = $signingCert->getExtension('id-ce-cRLDistributionPoints');
	if ($crlDistributionPoints && isset($crlDistributionPoints[0]['distributionPoint']['fullName'][0]['uniformResourceIdentifier'])) {
		return $crlDistributionPoints[0]['distributionPoint']['fullName'][0]['uniformResourceIdentifier'];
	}
	error_log("[Error] No CRL Distribution Points found.");
	return null;
}

function fetchCrl(string $crlUrl): ?X509
{
	try {
		$crlPem = file_get_contents($crlUrl);
		if ($crlPem) {
			$crl = new X509();
			return $crl->loadCRL($crlPem) ? $crl : null;
		}
	} catch (Exception $e) {
		error_log("[Error] CRL fetch failed: " . $e->getMessage());
	}
	return null;
}

function isCertRevoked(X509 $crl, X509 $signingCert): bool
{
	$serialNumber = $signingCert->getCurrentCert()['tbsCertificate']['serialNumber']->toString();

	$revokedList = $crl->getRevoked($serialNumber);

	if (empty($revokedList)) {
		return false;
	}

	foreach ($revokedList as $revoked) {
		if ($revoked['userCertificate'] === $serialNumber) {
			error_log("[Info] Certificate with serial number {$serialNumber} is revoked.");
			return true;
		}
	}

	return false;
}

// Webhook Verification
function verifyWebhook(array $headers, string $payloadData): bool
{
	$payloadJson = json_decode($payloadData, true);

	try {
		// Load the signing certificate from the payload
		$signingCertPem = $payloadJson['certificate'] ?? null;
		if (!$signingCertPem) {
			error_log("[Error] No signing certificate found in payload.");
			return false;
		}

		$signingCert = new X509();
		if (!$signingCert->loadX509($signingCertPem)) {
			error_log("[Error] Invalid signing certificate format.");
			return false;
		}

		// Load the CA certificate
		$caCert = fetchCaCert();
		if (!$caCert) {
			error_log("[Error] Failed to load CA certificate.");
			return false;
		}

		// Step 1: Verify signing certificate against the CA certificate
		if (!verifyCertAgainstCA($signingCert, $caCert)) {
			error_log("[Error] Signing certificate is not trusted by the CA.");
			return false;
		}

		// Step 2: Fetch CRL and check certificate revocation status
		$crlUrl = getCrlUrlFromCert($signingCert);
		if (!$crlUrl) {
			error_log("[Error] No valid CRL URL found in the signing certificate.");
			return false;
		}

		$crl = fetchCrl($crlUrl);
		if ($crl && isCertRevoked($crl, $signingCert)) {
			error_log("[Error] Certificate has been revoked.");
			return false;
		}

		// Step 3: Verify the signature
		if (!verifySignature($signingCert, $headers, $payloadData)) {
			error_log("[Error] Webhook signature verification failed.");
			return false;
		}

		error_log("[Success] Webhook verification succeeded.");
		return true;

	} catch (Exception $e) {
		error_log("[Error] Webhook verification failed: " . $e->getMessage());
		return false;
	}
}

verifyWebhook(getallheaders(), file_get_contents('php://input'));

<?php

use phpseclib3\Crypt\RSA;
use phpseclib3\File\X509;

// Constants
const CA_CERT_URL = "https://ca.tillo.io/";

function decodeBase64(string $data, string $prefix = 'v1a,'): string
{
	return base64_decode(ltrim($data, $prefix));
}

function fetchCaCert(): ?X509
{
	$caCertPem = file_get_contents(CA_CERT_URL);
	if (!$caCertPem) {
		error_log("[Error] Failed to load CA certificate from URL.");
		return null;
	}

	$caCert = new X509();
	return $caCert->loadX509($caCertPem) ? $caCert : null;
}

function verifySignature(X509 $signingCert, array $headers, string $payloadData): bool
{
	try {
		$signature = decodeBase64($headers['webhook-signature']);
		$data = $headers['webhook-id'] . '.' . $headers['webhook-timestamp'] . '.' .  $payloadData;

		return RSA::loadPublicKey($signingCert->getPublicKey())->verify($data, $signature);
	} catch (Exception $e) {
		error_log("[Error] Signature verification failed: " . $e->getMessage());
		return false;
	}
}

function verifyCertAgainstCA(X509 $signingCert, X509 $caCert): bool
{
	try {
		$caCertPem = $caCert->saveX509($caCert->getCurrentCert()); // Get the PEM-encoded string of the CA certificate
		$signingCertPem = $signingCert->saveX509($signingCert->getCurrentCert()); // Get the PEM-encoded string of the CA certificate

		$x509 = new X509();
		$x509->loadCA($caCertPem);
		$x509->loadX509($signingCertPem);
		$result = $x509->validateSignature();

		return $result === true;
	} catch (Exception $e) {
		error_log("[Error] Signing certificate verification against CA failed: " . $e->getMessage());
		return false;
	}
}
function getCrlUrlFromCert(X509 $signingCert): ?string {
	$crlDistributionPoints = $signingCert->getExtension('id-ce-cRLDistributionPoints');
	if ($crlDistributionPoints && isset($crlDistributionPoints[0]['distributionPoint']['fullName'][0]['uniformResourceIdentifier'])) {
		return $crlDistributionPoints[0]['distributionPoint']['fullName'][0]['uniformResourceIdentifier'];
	}
	error_log("[Error] No CRL Distribution Points found.");
	return null;
}

function fetchCrl(string $crlUrl): ?X509
{
	try {
		$crlPem = file_get_contents($crlUrl);
		if ($crlPem) {
			$crl = new X509();
			return $crl->loadCRL($crlPem) ? $crl : null;
		}
	} catch (Exception $e) {
		error_log("[Error] CRL fetch failed: " . $e->getMessage());
	}
	return null;
}

function isCertRevoked(X509 $crl, X509 $signingCert): bool
{
	$serialNumber = $signingCert->getCurrentCert()['tbsCertificate']['serialNumber']->toString();

	$revokedList = $crl->getRevoked($serialNumber);

	if (empty($revokedList)) {
		return false;
	}

	foreach ($revokedList as $revoked) {
		if ($revoked['userCertificate'] === $serialNumber) {
			error_log("[Info] Certificate with serial number {$serialNumber} is revoked.");
			return true;
		}
	}

	return false;
}

// Webhook Verification
function verifyWebhook(array $headers, string $payloadData): bool
{
	$payloadJson = json_decode($payloadData, true);

	try {
		// Load the signing certificate from the payload
		$signingCertPem = $payloadJson['certificate'] ?? null;
		if (!$signingCertPem) {
			error_log("[Error] No signing certificate found in payload.");
			return false;
		}

		$signingCert = new X509();
		if (!$signingCert->loadX509($signingCertPem)) {
			error_log("[Error] Invalid signing certificate format.");
			return false;
		}

		// Load the CA certificate
		$caCert = fetchCaCert();
		if (!$caCert) {
			error_log("[Error] Failed to load CA certificate.");
			return false;
		}

		// Step 1: Verify signing certificate against the CA certificate
		if (!verifyCertAgainstCA($signingCert, $caCert)) {
			error_log("[Error] Signing certificate is not trusted by the CA.");
			return false;
		}

		// Step 2: Fetch CRL and check certificate revocation status
		$crlUrl = getCrlUrlFromCert($signingCert);
		if (!$crlUrl) {
			error_log("[Error] No valid CRL URL found in the signing certificate.");
			return false;
		}

		$crl = fetchCrl($crlUrl);
		if ($crl && isCertRevoked($crl, $signingCert)) {
			error_log("[Error] Certificate has been revoked.");
			return false;
		}

		// Step 3: Verify the signature
		if (!verifySignature($signingCert, $headers, $payloadData)) {
			error_log("[Error] Webhook signature verification failed.");
			return false;
		}

		error_log("[Success] Webhook verification succeeded.");
		return true;

	} catch (Exception $e) {
		error_log("[Error] Webhook verification failed: " . $e->getMessage());
		return false;
	}
}

verifyWebhook(getallheaders(), file_get_contents('php://input'));
python
import base64
import json
import requests
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from OpenSSL import crypto
import traceback

# Constants
CA_CERT_URL = "https://ca.tillo.io/"

# Helper Functions
def decode_base64(data, prefix="v1a,"):
    """Decode base64 data and strip prefix if present."""
    return base64.b64decode(data.lstrip(prefix))

# Signature Verification
def verify_signature(signing_cert, headers, payload_data):
    try:
        signature = decode_base64(headers.get('webhook-signature'))
        data = f"{headers.get('webhook-id')}.{headers.get('webhook-timestamp')}.{payload_data}"

        signing_cert.public_key().verify(
            signature,
            data.encode("utf-8"),
            padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
            hashes.SHA256()
        )
        return True
    except Exception as e:
        print(f"[Error] Signature verification failed: {e}")
        return False

# Certificate Verification
def verify_cert_against_ca(signing_cert, ca_cert):
    """Verify that the signing certificate is signed by the CA certificate."""
    try:
        ca_cert.public_key().verify(
            signing_cert.signature,
            signing_cert.tbs_certificate_bytes,
            padding.PKCS1v15(),
            signing_cert.signature_hash_algorithm
        )
        return True
    except Exception as e:
        print(f"[Error] Signing certificate verification against CA failed: {e}")
        return False

def get_crl_url_from_cert(signing_cert):
    """Extract the CRL URL from the signing certificate."""
    try:
        for ext in signing_cert.extensions:
            if isinstance(ext.value, x509.CRLDistributionPoints):
                return ext.value[0].full_name[0].value  # Returns first CRL URL found
    except Exception as e:
        print(f"[Error] CRL URL extraction failed: {e}")
        return None
    print("[Error] No CRL Distribution Points found.")
    return None

def fetch_crl(crl_url):
    """Fetch the CRL from the distribution point."""
    try:
        response = requests.get(crl_url)
        response.raise_for_status()
        crl_pem = response.content
        return crypto.load_crl(crypto.FILETYPE_PEM, crl_pem)
    except Exception as e:
        print(f"[Error] CRL fetch failed: {e}")
        return None

def is_cert_revoked(crl, signing_cert):
    """Check if the signing certificate is revoked based on the CRL."""
    serial_number = format(signing_cert.serial_number, "X")
    if crl.get_revoked() is not None:
        for revoked in crl.get_revoked():
            if revoked.get_serial() == serial_number:
                print(f"[Info] Certificate with serial number {serial_number} is revoked.")
                return True
    return False

# Webhook Verification
def verify_webhook(headers, payload_bytes):
    payload_data = payload_bytes.decode('utf-8')
    payload_json = json.loads(payload_data)

    try:
        # Load signing certificate from payload
        signing_cert_pem = payload_json.get('certificate')
        signing_cert = x509.load_pem_x509_certificate(signing_cert_pem.encode("utf-8"), default_backend())

        # Load CA certificate from URL
        ca_cert_pem = requests.get(CA_CERT_URL).text
        ca_cert = x509.load_pem_x509_certificate(ca_cert_pem.encode("utf-8"), default_backend())

        # Step 1: Verify signing certificate against the CA certificate
        if not verify_cert_against_ca(signing_cert, ca_cert):
            print("[Error] Signing certificate is not trusted by the CA.")
            return False

        # Step 2: Fetch CRL and check certificate revocation status
        crl_url = get_crl_url_from_cert(signing_cert)
        if not crl_url:
            print("[Error] No valid CRL URL found in the signing certificate.")
            return False
        crl = fetch_crl(crl_url)
        if crl and is_cert_revoked(crl, signing_cert):
            print("[Error] Certificate has been revoked.")
            return False

        # Step 3: Verify signature
        if not verify_signature(signing_cert, headers, payload_data):
            print("[Error] Webhook signature verification failed.")
            return False

        print("[Success] Webhook verification succeeded.")
        return True

    except Exception as e:
        print(f"[Error] Webhook verification failed: {e}")
        traceback.print_exc()
        return False
import base64
import json
import requests
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from OpenSSL import crypto
import traceback

# Constants
CA_CERT_URL = "https://ca.tillo.io/"

# Helper Functions
def decode_base64(data, prefix="v1a,"):
    """Decode base64 data and strip prefix if present."""
    return base64.b64decode(data.lstrip(prefix))

# Signature Verification
def verify_signature(signing_cert, headers, payload_data):
    try:
        signature = decode_base64(headers.get('webhook-signature'))
        data = f"{headers.get('webhook-id')}.{headers.get('webhook-timestamp')}.{payload_data}"

        signing_cert.public_key().verify(
            signature,
            data.encode("utf-8"),
            padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
            hashes.SHA256()
        )
        return True
    except Exception as e:
        print(f"[Error] Signature verification failed: {e}")
        return False

# Certificate Verification
def verify_cert_against_ca(signing_cert, ca_cert):
    """Verify that the signing certificate is signed by the CA certificate."""
    try:
        ca_cert.public_key().verify(
            signing_cert.signature,
            signing_cert.tbs_certificate_bytes,
            padding.PKCS1v15(),
            signing_cert.signature_hash_algorithm
        )
        return True
    except Exception as e:
        print(f"[Error] Signing certificate verification against CA failed: {e}")
        return False

def get_crl_url_from_cert(signing_cert):
    """Extract the CRL URL from the signing certificate."""
    try:
        for ext in signing_cert.extensions:
            if isinstance(ext.value, x509.CRLDistributionPoints):
                return ext.value[0].full_name[0].value  # Returns first CRL URL found
    except Exception as e:
        print(f"[Error] CRL URL extraction failed: {e}")
        return None
    print("[Error] No CRL Distribution Points found.")
    return None

def fetch_crl(crl_url):
    """Fetch the CRL from the distribution point."""
    try:
        response = requests.get(crl_url)
        response.raise_for_status()
        crl_pem = response.content
        return crypto.load_crl(crypto.FILETYPE_PEM, crl_pem)
    except Exception as e:
        print(f"[Error] CRL fetch failed: {e}")
        return None

def is_cert_revoked(crl, signing_cert):
    """Check if the signing certificate is revoked based on the CRL."""
    serial_number = format(signing_cert.serial_number, "X")
    if crl.get_revoked() is not None:
        for revoked in crl.get_revoked():
            if revoked.get_serial() == serial_number:
                print(f"[Info] Certificate with serial number {serial_number} is revoked.")
                return True
    return False

# Webhook Verification
def verify_webhook(headers, payload_bytes):
    payload_data = payload_bytes.decode('utf-8')
    payload_json = json.loads(payload_data)

    try:
        # Load signing certificate from payload
        signing_cert_pem = payload_json.get('certificate')
        signing_cert = x509.load_pem_x509_certificate(signing_cert_pem.encode("utf-8"), default_backend())

        # Load CA certificate from URL
        ca_cert_pem = requests.get(CA_CERT_URL).text
        ca_cert = x509.load_pem_x509_certificate(ca_cert_pem.encode("utf-8"), default_backend())

        # Step 1: Verify signing certificate against the CA certificate
        if not verify_cert_against_ca(signing_cert, ca_cert):
            print("[Error] Signing certificate is not trusted by the CA.")
            return False

        # Step 2: Fetch CRL and check certificate revocation status
        crl_url = get_crl_url_from_cert(signing_cert)
        if not crl_url:
            print("[Error] No valid CRL URL found in the signing certificate.")
            return False
        crl = fetch_crl(crl_url)
        if crl and is_cert_revoked(crl, signing_cert):
            print("[Error] Certificate has been revoked.")
            return False

        # Step 3: Verify signature
        if not verify_signature(signing_cert, headers, payload_data):
            print("[Error] Webhook signature verification failed.")
            return False

        print("[Success] Webhook verification succeeded.")
        return True

    except Exception as e:
        print(f"[Error] Webhook verification failed: {e}")
        traceback.print_exc()
        return False

FAQ

How often are Webhooks retried?

When attempting to deliver webhooks to your specific Endpoint, we will attempt to send it 3 times. During which, both the webhook-timestamp and webhook-signature will be regenerated, however the webhook-id will remain the same.

We will attempt to wait 10 seconds for a response from your server each time we attempt to deliver the webhook notification. After the first attempt, we will gradually delay the next attempt.

What's the difference between the timestamp in the header and the timestamp in the payload?

The timestamp contained within the webhook-timestamp header is when we attempted to send the webhook request to your server. The timestamp property in the payload is when the actual event occurred.

If we need to retry requests due to the first notification failing, the webhook-timestamp will be updated but the timestamp in the payload will remain the same.

Will you verify the SSL Certificate when delivering the webhook notification?

Yes, we will attempt to verify the SSL certificate on your endpoint when attempting to deliver the webhook notification.

You will need a valid certificate on your endpoint. If your certificate is invalid, we will immediately fail the request without retrying.