Guides
Webhooks
Receive events in your backend, verify their signature, and handle retries.
Webhooks let Tilt Pay call your server when something happens: a user is created, an identity check is approved, a deposit completes. Each event is a signed POST with a JSON body.
Set up an endpoint#
You can register endpoints in the Hub or with the API (both manage the same endpoints).
- In the Hub, open Developers, then Webhooks, and create an endpoint.
- Choose the URL, the environment (sandbox or live) and the event types you want (or all of them).
- Copy the signing secret (
whsec_…). It is shown once, when you create the endpoint and when you rotate it. - Use Send test event to check that your server answers.
An endpoint only receives events of its own environment: a sandbox endpoint receives events generated with sandbox keys, a live endpoint those of live keys.
URL rules#
- HTTPS is required. The exception is a sandbox endpoint pointing to
localhost,127.0.0.1or[::1]during development. - No credentials in the URL, no private or reserved IP addresses, no internal host names, no sensitive ports. The check is repeated for every delivery, after DNS resolution.
- One URL can exist only once per project and environment. A project can have up to 20 endpoints.
Events#
Subscribe to the types you need, or to all of them with *. The same events are available in the event log.
| Event type | When it is sent | data.object |
|---|---|---|
| user.created | A user was created in your project. | User |
| user.suspended | A user was suspended and can no longer transact. | User |
| user.unsuspended | A suspension was lifted. | User |
| kyc.approved | The identity verification (KYC) of a user was approved. | Kyc |
| kyc.rejected | The identity verification (KYC) of a user was rejected. | Kyc |
| transaction.received | Funds were received for a user (a deposit was detected). | Transaction |
| transaction.completed | A transaction reached the completed status. | Transaction |
| withdrawal.completed | A withdrawal was paid out. | Transaction |
| withdrawal.failed | A withdrawal failed and was not paid out. | Transaction |
Subscribe to * to receive every type, including new ones added later. Open an event below to see the exact body sent to your endpoint.
user.createdA user was created in your project.
{
"id": "evt_0b2c8f6e-41f9-4c0a-8d55-6f6a3c1d9e21",
"object": "event",
"type": "user.created",
"created_at": "2026-10-08T09:30:00.000Z",
"created": "2026-10-08T09:30:00.000Z",
"project_id": "b6f1c0e2-7a52-4d2c-9f8e-2f2f0a6d1c11",
"livemode": false,
"data": {
"object": {
"id": "4821",
"object": "user",
"phone": "+33612345678",
"external_id": "cust_8f2a",
"verified": true,
"kyc_status": "approved",
"suspended": false,
"livemode": false,
"created_at": "2026-10-08T09:30:00.000Z",
"wallet_status": "ready",
"last_login_at": null
}
}
}user.suspendedA user was suspended and can no longer transact.
{
"id": "evt_0b2c8f6e-41f9-4c0a-8d55-6f6a3c1d9e21",
"object": "event",
"type": "user.suspended",
"created_at": "2026-10-08T09:30:00.000Z",
"created": "2026-10-08T09:30:00.000Z",
"project_id": "b6f1c0e2-7a52-4d2c-9f8e-2f2f0a6d1c11",
"livemode": false,
"data": {
"object": {
"id": "4821",
"object": "user",
"phone": "+33612345678",
"external_id": "cust_8f2a",
"verified": true,
"kyc_status": "approved",
"suspended": false,
"livemode": false,
"created_at": "2026-10-08T09:30:00.000Z",
"wallet_status": "ready",
"last_login_at": null
}
}
}user.unsuspendedA suspension was lifted.
{
"id": "evt_0b2c8f6e-41f9-4c0a-8d55-6f6a3c1d9e21",
"object": "event",
"type": "user.unsuspended",
"created_at": "2026-10-08T09:30:00.000Z",
"created": "2026-10-08T09:30:00.000Z",
"project_id": "b6f1c0e2-7a52-4d2c-9f8e-2f2f0a6d1c11",
"livemode": false,
"data": {
"object": {
"id": "4821",
"object": "user",
"phone": "+33612345678",
"external_id": "cust_8f2a",
"verified": true,
"kyc_status": "approved",
"suspended": false,
"livemode": false,
"created_at": "2026-10-08T09:30:00.000Z",
"wallet_status": "ready",
"last_login_at": null
}
}
}kyc.approvedThe identity verification (KYC) of a user was approved.
{
"id": "evt_0b2c8f6e-41f9-4c0a-8d55-6f6a3c1d9e21",
"object": "event",
"type": "kyc.approved",
"created_at": "2026-10-08T09:30:00.000Z",
"created": "2026-10-08T09:30:00.000Z",
"project_id": "b6f1c0e2-7a52-4d2c-9f8e-2f2f0a6d1c11",
"livemode": false,
"data": {
"object": {
"object": "kyc",
"user_id": "4821",
"status": "approved",
"livemode": false
}
}
}kyc.rejectedThe identity verification (KYC) of a user was rejected.
{
"id": "evt_0b2c8f6e-41f9-4c0a-8d55-6f6a3c1d9e21",
"object": "event",
"type": "kyc.rejected",
"created_at": "2026-10-08T09:30:00.000Z",
"created": "2026-10-08T09:30:00.000Z",
"project_id": "b6f1c0e2-7a52-4d2c-9f8e-2f2f0a6d1c11",
"livemode": false,
"data": {
"object": {
"object": "kyc",
"user_id": "4821",
"status": "approved",
"livemode": false
}
}
}transaction.receivedFunds were received for a user (a deposit was detected).
{
"id": "evt_0b2c8f6e-41f9-4c0a-8d55-6f6a3c1d9e21",
"object": "event",
"type": "transaction.received",
"created_at": "2026-10-08T09:30:00.000Z",
"created": "2026-10-08T09:30:00.000Z",
"project_id": "b6f1c0e2-7a52-4d2c-9f8e-2f2f0a6d1c11",
"livemode": false,
"data": {
"object": {
"id": "90412",
"object": "transaction",
"user_id": "4821",
"type": "deposit",
"direction": "in",
"amount_usd": 100,
"fee_usd": 0.6,
"status": "completed",
"fiat_currency": "USD",
"fiat_amount": 100,
"livemode": false,
"created_at": "2026-10-08T09:31:00.000Z"
}
}
}transaction.completedA transaction reached the `completed` status.
{
"id": "evt_0b2c8f6e-41f9-4c0a-8d55-6f6a3c1d9e21",
"object": "event",
"type": "transaction.completed",
"created_at": "2026-10-08T09:30:00.000Z",
"created": "2026-10-08T09:30:00.000Z",
"project_id": "b6f1c0e2-7a52-4d2c-9f8e-2f2f0a6d1c11",
"livemode": false,
"data": {
"object": {
"id": "90412",
"object": "transaction",
"user_id": "4821",
"type": "deposit",
"direction": "in",
"amount_usd": 100,
"fee_usd": 0.6,
"status": "completed",
"fiat_currency": "USD",
"fiat_amount": 100,
"livemode": false,
"created_at": "2026-10-08T09:31:00.000Z"
}
}
}withdrawal.completedA withdrawal was paid out.
{
"id": "evt_0b2c8f6e-41f9-4c0a-8d55-6f6a3c1d9e21",
"object": "event",
"type": "withdrawal.completed",
"created_at": "2026-10-08T09:30:00.000Z",
"created": "2026-10-08T09:30:00.000Z",
"project_id": "b6f1c0e2-7a52-4d2c-9f8e-2f2f0a6d1c11",
"livemode": false,
"data": {
"object": {
"id": "90412",
"object": "transaction",
"user_id": "4821",
"type": "deposit",
"direction": "in",
"amount_usd": 100,
"fee_usd": 0.6,
"status": "completed",
"fiat_currency": "USD",
"fiat_amount": 100,
"livemode": false,
"created_at": "2026-10-08T09:31:00.000Z"
}
}
}withdrawal.failedA withdrawal failed and was not paid out.
{
"id": "evt_0b2c8f6e-41f9-4c0a-8d55-6f6a3c1d9e21",
"object": "event",
"type": "withdrawal.failed",
"created_at": "2026-10-08T09:30:00.000Z",
"created": "2026-10-08T09:30:00.000Z",
"project_id": "b6f1c0e2-7a52-4d2c-9f8e-2f2f0a6d1c11",
"livemode": false,
"data": {
"object": {
"id": "90412",
"object": "transaction",
"user_id": "4821",
"type": "deposit",
"direction": "in",
"amount_usd": 100,
"fee_usd": 0.6,
"status": "completed",
"fiat_currency": "USD",
"fiat_amount": 100,
"livemode": false,
"created_at": "2026-10-08T09:31:00.000Z"
}
}
}The request you receive#
Tilt Pay sends POST <your url> with Content-Type: application/json. The body is the same Event object that List events returns, plus created and project_id:
{
"id": "evt_0b2c8f6e-41f9-4c0a-8d55-6f6a3c1d9e21",
"object": "event",
"type": "kyc.approved",
"created_at": "2026-10-08T09:30:00.000Z",
"created": "2026-10-08T09:30:00.000Z",
"project_id": "b6f1c0e2-7a52-4d2c-9f8e-2f2f0a6d1c11",
"livemode": false,
"data": { "object": { "object": "kyc", "user_id": "4821", "status": "approved", "livemode": false } }
}id is identical to the one returned by GET /v1/events. It is also your idempotency key: an event can be delivered more than once (retries, manual replay) with the same id. Store the ids you have processed and ignore duplicates. Do not rely on the order of delivery.The shared fields (id, object, type, created_at, livemode, data) are identical to the event log, so one parser reads both. created repeats created_at, and project_id is only present in webhook bodies. Personal data such as the user profile is never part of an event.
| Header | Value |
|---|---|
Tilt-Signature | t=<unix seconds>,v1=<hex> (see below) |
Tilt-Event-Id | The event id (evt_…) |
Tilt-Event-Type | The event type |
Tilt-Delivery-Attempt | Attempt number, from 1 to 7 |
User-Agent | TiltPay-Webhooks/1.0 |
Verify the signature#
Always verify the signature before trusting a request: anyone can post to your URL. The signature is an HMAC-SHA256, in hexadecimal, of the string <t>.<raw body> using your endpoint's signing secret, where t is the timestamp (Unix seconds) from the header.
- Read the raw body, before any JSON parsing. Re-serializing the JSON changes the bytes and breaks the signature.
- Extract
tand everyv1value from theTilt-Signatureheader. Ignore schemes you do not know. - Reject the request if
|now - t|is greater than 300 seconds. This protects you from replayed requests. - Compute the HMAC with your secret and compare it to each
v1value in constant time. Accept if one matches.
# Compute the expected signature of a payload yourself (here: the test vector below).
SECRET='whsec_test_secret'
T=1700000000
BODY='{"id":"evt_1","type":"user.created"}'
printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex
# Replay a signed delivery against your local server while developing.
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
curl -X POST "http://localhost:4000/webhooks/tilt" \
-H "Content-Type: application/json" \
-H "Tilt-Signature: t=$T,v1=$SIG" \
-d "$BODY"A minimal Express receiver. express.raw hands you the unparsed body:
import express from 'express'
const app = express()
app.post('/webhooks/tilt', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyTiltSignature(req.body, req.get('Tilt-Signature'), [process.env.TILT_WEBHOOK_SECRET])
if (!ok) return res.sendStatus(400)
const event = JSON.parse(req.body.toString('utf8'))
// 1. Skip duplicates: have you already processed event.id?
// 2. Enqueue the work, then answer fast.
res.sendStatus(200)
})Test vectors#
Use these to check your implementation. With the secret whsec_test_secret, the timestamp t = 1700000000 and the exact body {"id":"evt_1","type":"user.created"}:
v1 = f05b1ef15f6e243adcc340e544948703c6d81cf305301168ffc9986284c88f05
Tilt-Signature: t=1700000000,v1=f05b1ef15f6e243adcc340e544948703c6d81cf305301168ffc9986284c88f05During a secret rotation the header carries two v1 values (new secret first). With the extra secret whsec_old_secret:
Tilt-Signature: t=1700000000,v1=f05b1ef15f6e243adcc340e544948703c6d81cf305301168ffc9986284c88f05,v1=c9ba1291ccba55af61e75bd999bc248b07c874dba5bce6214dfbcae0fb3f7132Success, failure and retries#
- Success is any
2xxanswer. The body is ignored. - Anything else is a failure: another status, no answer within 10 seconds, a network or TLS error. Redirects are never followed: a
3xxis a failure. - After a failure Tilt Pay retries with exponential backoff: after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, then 24 hours. That is 7 attempts at most, then the delivery is marked
failed. - Delivery is at least once. Answer fast and process asynchronously.
- The Hub lists every delivery with its attempts, your server's response and its latency. You can replay a delivery manually: it re-sends the same event, with the same
id.
Rotate a secret without downtime#
Rotate the secret from the endpoint's page. For a grace period (24 hours by default, from 0 to 168 hours) both secrets sign every delivery, so the header contains two v1 values. Deploy the new secret, then let the old one expire. With a grace period of 0 the old secret stops signing immediately.
Manage endpoints with the API#
An API key can manage the webhook endpoints of its own project and environment, in the same way it manages users. The environment is the one of the key: a sandbox key creates and sees sandbox endpoints only. An endpoint of another project or environment answers 404 webhook_endpoint_not_found.
| Operation | Route |
|---|---|
| Create an endpoint | POST /v1/webhook-endpoints |
| List endpoints | GET /v1/webhook-endpoints |
| Retrieve an endpoint | GET /v1/webhook-endpoints/{id} |
| Update an endpoint | PATCH /v1/webhook-endpoints/{id} |
| Delete an endpoint | DELETE /v1/webhook-endpoints/{id} |
| Rotate the signing secret | POST /v1/webhook-endpoints/{id}/rotate-secret |
| Send a test event | POST /v1/webhook-endpoints/{id}/test |
curl -X POST "https://sdk.tilt-pay.com/v1/webhook-endpoints" \
-H "Authorization: Bearer $TILT_PAY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7b1c0d36-5f0e-4d2a-9a43-0b8c6e2f1a90" \
-d '{"url": "https://example.com/webhooks/tilt-pay", "event_types": ["transaction.completed", "kyc.approved"]}'- The response of the creation and of the rotation contains the signing secret
whsec_…. It is never shown again: other responses only carrysecret_last4. A replay of the sameIdempotency-Keyreturns the same response. event_typesis a list of event types, or["*"]for all of them (up to 10 entries). The URL rules above apply: a refused URL answers422 invalid_webhook_urlwithparam: "url".- An endpoint is unique per URL and environment (
409 webhook_endpoint_exists), with at most 20 per project (409 webhook_endpoint_limit). - Update with
PATCH: send only the fields to change (url,description,event_types,enabled). Settingenabled: trueagain re-enables an endpoint that was disabled after repeated failures.disabled_reasontells why it was disabled (manualorconsecutive_failures). - Rotate with
grace_period_hours(0 to 168, default 24). While the grace period lasts,previous_secret_expires_atis set and both secrets sign every delivery;0revokes the old secret at once. See Rotate a secret without downtime. - Delete answers
200with{ "id": "…", "object": "webhook_endpoint", "deleted": true }and removes the delivery history too. It cannot be undone. - Test sends one signed
webhook.testevent, never retried, and tells you how your server answered:
{
"object": "webhook_test",
"endpoint_id": "0b2c8f6e-41f9-4c0a-8d55-6f6a3c1d9e21",
"event_id": "evt_0b2c8f6e-41f9-4c0a-8d55-6f6a3c1d9e21",
"status": "succeeded",
"response_status": 200,
"duration_ms": 84,
"error": null
}error is null on success, otherwise http_<status>, timeout, connection_refused, connection_reset, dns_error, tls_error, host_unreachable, ssrf_blocked, connection_error, internal_error (an error on Tilt Pay's side: the delivery is retried) or queue_full (too many deliveries pending for the endpoint: the event was not queued). The delivery history and manual replay are in the Hub.
Test events#
Send test event in the Hub posts a webhook.test event once, without retries, even to a disabled endpoint. Its data is { "message": "…" }. You can also trigger real events in the sandbox with the simulator.