Appearance
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.
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.
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.
Attempt | Delay (seconds) |
---|---|
1 | - |
2 | 10 |
3 | 100 |
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.
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.