Webhooks
Webhooks allow your organization to receive real-time notifications about events in Rillet, such as when an invoice is created or a customer is updated. When a subscribed event occurs, we'll send an HTTP POST request to a URL you provide.
This guide will walk you through setting up a webhook, verifying incoming requests, and understanding the different event types.
Setting Up a Webhook
Your organization can manage its webhooks directly from the Rillet dashboard.
- From your Organization Settings, navigate to the Webhooks page.
- Provide a descriptive Name for your webhook (e.g., "Production Notifications").
- Enter your endpoint URL. This is the publicly accessible URL where we will send
POSTrequests. It must use TLS (HTTPS). - Choose the events you want to subscribe to. Events are grouped by Entity (e.g.,
Invoice,Customer). For each entity, you can select event types you wish to receive. - Ensure the Enabled toggle is on to activate the webhook immediately.
- Click Create.
📝 Note on Limits:
An organization may configure up to 5 webhooks. Each webhook can subscribe to as many events as needed. We recommend using a single webhook endpoint for all event notifications and handling the event routing within your application.
After creating the webhook, a Webhook Token is generated. This token is used to sign and verify every webhook request we send. You can view your token or regenerate it at any time from the webhook settings page. Regenerating a token will immediately invalidate the old one.
Receiving Webhook Events
When an event you've subscribed to occurs, we will send a POST request to your configured URL. The request contains a JSON payload and several special headers.
Headers
Every webhook request includes the following headers:
| Header | Description |
|---|---|
X-Rillet-Signature | A Base64-encoded HMAC-SHA256 signature used to verify the request's authenticity. |
X-Rillet-Timestamp | The ISO-8601 timestamp of when the event was sent. |
X-Rillet-Id | A unique UUID for this specific event delivery. Use this for idempotency. |
X-Rillet-Entity | The type of object the event relates to (e.g., INVOICE, CREDIT_MEMO). |
X-Rillet-Event | The type of action that occurred (e.g., CREATED, UPDATED). |
Responding to a Webhook
To acknowledge receipt, your endpoint must return a successful HTTP status code (2xx) within 30 seconds. If our request times out or we receive a non-2xx response, we'll consider the delivery a failure and will retry sending the webhook according to the retry mechanism below. Any response body or header is ignored.
Webhook delivery is assured through a retry mechanism. We attempt delivery in groups of 3 retries using exponential backoff. If all 3 attempts fail, we will retry the group up to 5 times. After 5 failed groups, the webhook will be marked as failed and no further attempts will be made.
Verifying Signatures
Verifying the X-Rillet-Signature is critical for security. It confirms that the webhook was sent by us and that its payload has not been tampered with during transit.
The signature is a HMAC-SHA256 hash generated using your webhook token as the key. The final binary hash is Base64 encoded before being sent in the header.
⚠️ Important:
TheX-Rillet-Signatureheader may contain multiple signatures (up to 10), separated by commas. This enables token rotation without downtime. Your application should split the header value on commas, trim each signature, and attempt to verify each one against your known webhook token(s). If there are more than 10 signatures, you should ignore the request. If any signature matches, the request is valid. If none match, the request should be rejected.
Here’s how to verify a signature:
- Extract Headers and Body: Get the
X-Rillet-Timestamp,X-Rillet-Id,X-Rillet-Entity,X-Rillet-Event, andX-Rillet-Signaturefrom the request headers, along with the raw request body. - Prepare the
signedPayload: Create a string by concatenating the timestamp, ID, entity, event, and the raw request body, each separated by a dot (.).- Format:
$timestamp.$id.$entity.$event.$body
- Format:
- Compute the Expected Signature: Calculate an HMAC-SHA256 digest of the
signedPayloadusing your webhook token as the key. Encode the resulting binary hash as a Base64 string. - Compare Signatures: Split the
X-Rillet-Signatureheader on commas, trim each signature, and compare your computed Base64-encoded signature with each value. Use a constant-time comparison function to prevent timing attacks.
Examples
Below are fully functional examples of implementations in TypeScript, Python, and Java. The computed signatures are accurate and reflect our signing method. They can be used to validate your implementation.
TypeScript
import * as crypto from 'crypto';
import {Buffer} from 'node:buffer';
// Your webhook token, stored securely (e.g., as an environment variable)
const WEBHOOK_TOKEN = 'U291dGggUGFyayAtIE1lZGljaW5hbCBGcmllZCBDaGlja2Vu';
// The body of the webhook, as it was sent to you and before any parsing is performed.
const RAW_BODY = '{"foo":"bar","baz":"qux"}'
// The headers of the webhook. All 5 are required in order to pass verification.
const HEADERS = {
'X-Rillet-Signature': 's1HZBdKVbE/9h3qxJtAWb5M+BX5MfkMt9g9mTZFT19c=, c29tZSByYW5kb20gc2lnbmF0dXJlIGkgaGFkIHRvIG1ha2UgdXA=',
'X-Rillet-Timestamp': '2025-07-29T02:52:25Z',
'X-Rillet-Id': '01985418-1440-77ac-8741-eff80aec8fb0',
'X-Rillet-Entity': 'INVOICE',
'X-Rillet-Event': 'CREATED',
}
function verifySignatures(webhookSignatures: string[], token: string, signedPayload: string) {
let verified = false;
for (const webhookSignature of webhookSignatures) {
const expectedSignature = crypto
.createHmac('sha256', Buffer.from(token, 'base64'))
.update(signedPayload)
.digest('base64');
const receivedSigBuffer = Buffer.from(webhookSignature, 'base64');
const expectedSigBuffer = Buffer.from(expectedSignature, 'base64');
if (
receivedSigBuffer.length === expectedSigBuffer.length &&
crypto.timingSafeEqual(receivedSigBuffer, expectedSigBuffer)
) {
verified = true;
break;
}
}
return verified;
}
/**
* Verifies the signature of an incoming Rillet webhook.
* Throws an error if the signature is invalid or headers are missing.
*/
export function verifyRilletWebhook(
headers: Record<string, string>,
rawBody: string,
token: string
): void {
const webhookSignatures = headers['X-Rillet-Signature']?.split(',').map(s => s.trim()) || [];
const webhookTimestamp = headers['X-Rillet-Timestamp'];
const webhookEventId = headers['X-Rillet-Id'];
const webhookEntity = headers['X-Rillet-Entity'];
const webhookEvent = headers['X-Rillet-Event'];
if (
webhookSignatures.length === 0 ||
webhookSignatures.length > 10 ||
!webhookTimestamp ||
!webhookEventId ||
!webhookEntity ||
!webhookEvent
) {
throw new Error('Missing required Rillet webhook headers or too many signatures.');
}
const signedPayload = `${webhookTimestamp}.${webhookEventId}.${webhookEntity}.${webhookEvent}.${rawBody}`;
let verified = verifySignatures(webhookSignatures, token, signedPayload);
if (!verified) {
throw new Error(`Invalid webhook signature. Could not verify any signature.`);
}
console.log('Webhook signature verified successfully.');
}
verifyRilletWebhook(HEADERS, RAW_BODY, WEBHOOK_TOKEN)Python
import hashlib
import hmac
import base64
# Your webhook token, stored securely (e.g., as an environment variable)
WEBHOOK_TOKEN = 'U291dGggUGFyayAtIE1lZGljaW5hbCBGcmllZCBDaGlja2Vu'
# The body of the webhook, as it was sent to you and before any parsing is performed.
RAW_BODY = '{"foo":"bar","baz":"qux"}'
# The headers of the webhook. All 5 are required in order to pass verification.
HEADERS = {
'X-Rillet-Signature': 's1HZBdKVbE/9h3qxJtAWb5M+BX5MfkMt9g9mTZFT19c=, c29tZSByYW5kb20gc2lnbmF0dXJlIGkgaGFkIHRvIG1ha2UgdXA=',
'X-Rillet-Timestamp': '2025-07-29T02:52:25Z',
'X-Rillet-Id': '01985418-1440-77ac-8741-eff80aec8fb0',
'X-Rillet-Entity': 'INVOICE',
'X-Rillet-Event': 'CREATED',
}
def verify_rillet_webhook(headers, raw_body, token):
"""
Verifies the signature of an incoming Rillet webhook.
Raises an exception if the signature is invalid, headers are missing, or too many signatures are present.
"""
webhook_signatures = [s.strip() for s in headers.get('X-Rillet-Signature', '').split(',') if s.strip()]
webhook_timestamp = headers.get('X-Rillet-Timestamp')
webhook_event_id = headers.get('X-Rillet-Id')
webhook_entity = headers.get('X-Rillet-Entity')
webhook_event = headers.get('X-Rillet-Event')
if (
not webhook_signatures or
len(webhook_signatures) > 10 or
not webhook_timestamp or
not webhook_event_id or
not webhook_entity or
not webhook_event
):
raise ValueError('Missing required Rillet webhook headers or too many signatures.')
signed_payload = f"{webhook_timestamp}.{webhook_event_id}.{webhook_entity}.{webhook_event}.{raw_body}"
verified = verify_signatures(signed_payload, token, webhook_signatures)
if not verified:
raise ValueError(f"Invalid webhook signature. Could not verify any signature.")
print('Webhook signature verified successfully.')
def verify_signatures(signed_payload, token, webhook_signatures):
for webhook_signature in webhook_signatures:
token_bytes = base64.b64decode(token)
expected_signature = hmac.new(
token_bytes,
signed_payload.encode('utf-8'),
hashlib.sha256
).digest()
expected_signature_b64 = base64.b64encode(expected_signature).decode('utf-8')
received_sig_bytes = base64.b64decode(webhook_signature)
expected_sig_bytes = base64.b64decode(expected_signature_b64)
if hmac.compare_digest(received_sig_bytes, expected_sig_bytes):
return True
return False
if __name__ == "__main__":
verify_rillet_webhook(HEADERS, RAW_BODY, WEBHOOK_TOKEN)Java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Map;
public class RilletWebhookVerifier {
private static final String WEBHOOK_TOKEN = "U291dGggUGFyayAtIE1lZGljaW5hbCBGcmllZCBDaGlja2Vu";
private static final String RAW_BODY = "{\"foo\":\"bar\",\"baz\":\"qux\"}";
private static final Map<String, String> HEADERS = Map.of(
"X-Rillet-Signature", "s1HZBdKVbE/9h3qxJtAWb5M+BX5MfkMt9g9mTZFT19c=, c29tZSByYW5kb20gc2lnbmF0dXJlIGkgaGFkIHRvIG1ha2UgdXA=",
"X-Rillet-Timestamp", "2025-07-29T02:52:25Z",
"X-Rillet-Id", "01985418-1440-77ac-8741-eff80aec8fb0",
"X-Rillet-Entity", "INVOICE",
"X-Rillet-Event", "CREATED"
);
/**
* Verifies the signature of an incoming Rillet webhook.
* Throws an exception if the signature is invalid or headers are missing.
*/
public static void verifyRilletWebhook(
Map<String, String> headers,
String rawBody,
String token
) throws NoSuchAlgorithmException, InvalidKeyException {
String webhookSignatureHeader = headers.get("X-Rillet-Signature");
String webhookTimestamp = headers.get("X-Rillet-Timestamp");
String webhookEventId = headers.get("X-Rillet-Id");
String webhookEntity = headers.get("X-Rillet-Entity");
String webhookEvent = headers.get("X-Rillet-Event");
String[] webhookSignatures = webhookSignatureHeader == null ? new String[0] :
java.util.Arrays.stream(webhookSignatureHeader.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toArray(String[]::new);
if (
webhookSignatures.length == 0 ||
webhookSignatures.length > 10 ||
webhookTimestamp == null ||
webhookEventId == null ||
webhookEntity == null ||
webhookEvent == null
) {
throw new IllegalArgumentException("Missing required Rillet webhook headers or too many signatures.");
}
String signedPayload = String.format("%s.%s.%s.%s.%s", webhookTimestamp, webhookEventId, webhookEntity, webhookEvent, rawBody);
boolean verified = verifySignatures(token, webhookSignatures, signedPayload);
if (!verified) {
throw new SecurityException("Invalid webhook signature. Could not verify any signature.");
}
System.out.println("Webhook signature verified successfully.");
}
private static boolean verifySignatures(String token, String[] webhookSignatures, String signedPayload) throws NoSuchAlgorithmException, InvalidKeyException {
for (String webhookSignature : webhookSignatures) {
SecretKeySpec secretKeySpec = new SecretKeySpec(Base64.getDecoder().decode(token), "HmacSHA256");
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(secretKeySpec);
byte[] expectedSignatureBytes = mac.doFinal(signedPayload.getBytes(StandardCharsets.UTF_8));
byte[] webhookSignatureBytes = Base64.getDecoder().decode(webhookSignature);
if (MessageDigest.isEqual(webhookSignatureBytes, expectedSignatureBytes)) { // Ensure you use a constant-time comparison.
return true;
}
}
return false;
}
public static void main(String[] args) {
try {
verifyRilletWebhook(HEADERS, RAW_BODY, WEBHOOK_TOKEN);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
System.err.println("Error verifying webhook: " + e.getMessage());
}
}
}Event Types and Payloads
The specific values for the X-Rillet-Entity and X-Rillet-Event headers, as well as the structure of the JSON request body, will vary depending on the event that triggered the webhook.
For a complete and up-to-date list of all available webhook events and their corresponding payload schemas, please refer to our official OpenAPI Specification. This document serves as the single source of truth for all webhook structures.
Once an event signature has been verified, the payload may be parsed into the request model specified for the webhook in the OpenAPI Specification.
To maintain backwards compatibility, your application should ignore any unrecognized X-Rillet-Entity and X-Rillet-Event header values. This will ensure that your application continues to function correctly even if new event types are added in the future.
Updated 5 months ago
