Webhooks: events, signatures and delivery
Register a public HTTPS endpoint, prove control and receive signed invoice and payment events. Delivery is queued durably; processing the same event again must be safe for your order.
On this page
Where events come from#
invoice.created does not mean paid. Payment events come from a verified server operation after a current gateway read; an incoming event name alone is insufficient.
Gateway → Kevmi ingress and Kevmi → merchant delivery are different legs with separate secrets. The browser, return navigation and widget messages do not set financial state.
| Event | Source |
|---|---|
| invoice.created | Invoice persisted |
| invoice.updated | Link availability changed |
| payment.succeeded | Exact payment verified by the server |
| payment.failed | Failed operation verified |
| payment.refunded | Full refund verified |
| payment.partially_refunded | Partial refund verified |
| payment.disputed | Dispute verified |
| payment.review_required | Operation requires additional review |
Register and verify an endpoint#
URLs cannot target IP addresses, localhost, private networks, nonstandard ports or contain credentials. DNS is checked before delivery; outbound POST requests do not follow redirects. An account can register up to five endpoints.
The verification event has livemode=false. A successful HTTP status alone is insufficient: the challenge must match exactly. Rotating the secret requires verification again.
- 1
At /account#webhooks in your merchant account, enter a public HTTPS URL, description and the event types you need.
- 2
Save the issued secret in server-side storage. Its full value is shown once.
- 3
Verify signatures over the original request bytes.
- 4
Run endpoint verification. For endpoint.verification, return JSON echoing data.challenge: {"challenge":"the received value"}.
- 5
Enable the verified endpoint and inspect delivery history.
Event envelope#
id stays the same across every delivery attempt of an event. createdAt is a Unix timestamp in milliseconds, and apiVersion identifies the contract version. data contains the persisted server object; the example is shortened.
livemode=true distinguishes a real server event from endpoint verification. It does not mean an invoice is paid: check type and payment data. Handle known event types and validate the schema.
{
"id": "evt_example",
"type": "invoice.created",
"apiVersion": "2026-10-21",
"createdAt": 1792540800000,
"livemode": true,
"data": {
"invoice": {
"id": "lnk_example",
"orderId": "order-1042",
"currency": "USD",
"amountMinor": 500,
"vatCustomerPercent": 100,
"feeCustomerPercent": 0
}
}
}Verify HMAC-SHA256#
Kevmi-Signature contains t=seconds,v1=hex. The signature is HMAC-SHA256 over the timestamp string, a dot and the original body: timestamp + "." + rawBody. Use the specific endpoint secret and a constant-time comparison.
Reject signatures whose timestamp differs from server time by more than 300 seconds in either direction. Synchronize clocks. Do not parse and reserialize JSON before verification: whitespace and property order affect the signed bytes.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyKevmi(rawBody, header, secret, nowSeconds) {
const match = /^t=(\d{10}),v1=([a-f0-9]{64})$/.exec(header || '');
if (!match || Math.abs(nowSeconds - Number(match[1])) > 300) {
return false;
}
const expected = createHmac('sha256', secret)
.update(match[1] + '.')
.update(rawBody)
.digest();
const received = Buffer.from(match[2], 'hex');
return expected.length === received.length
&& timingSafeEqual(expected, received);
}
// Read bytes before JSON parsing; cap the request body.
// secret comes from your server secret store.
const valid = verifyKevmi(rawBody, signature, secret,
Math.floor(Date.now() / 1000));
if (!valid) throw new Error('Invalid webhook signature');
const event = JSON.parse(rawBody.toString('utf8'));
// Validate the event schema and persist its ID together
// with your order update in one database transaction.
// Return a 2xx response after durable acceptance.Retries and acknowledgement#
Any 2xx response acknowledges an ordinary event. Persist the event durably before responding; expensive work can continue through your own queue. The response timeout is eight seconds. A network error or non-2xx response schedules a retry.
There are at most six attempts: the initial delivery plus delays of one minute, five minutes, 30 minutes, two hours and 12 hours after successive failures. These are queue schedules, not guarantees of exact delivery time. Exhausting attempts marks the delivery failed.
Delivery is at least once and does not promise strict ordering. Persist the unique event ID in the same transaction as your order update. An already applied duplicate should receive 2xx without fulfilling the order again.
A manual retry restarts delivery attempts while preserving the original event ID, delivery ID and exact body. Each attempt gets a fresh signature timestamp. Pausing an endpoint preserves its queue; deleting it cancels pending and failed deliveries.
Delivery history and reconciliation#
The interface shows endpoint state, recent deliveries, attempt counts, last HTTP response, error and next attempt time. These diagnostics complement your own order records.
GET /api/v1/events with events:read returns up to 50 events and nextCursor. Pass the opaque nextCursor unchanged as cursor; null marks the end. Ordering uses both createdAt and id. The legacy before Unix-millisecond boundary remains available separately and cannot be combined with cursor. Verified endpoints receive subscribed events no earlier than their registration; paused endpoints keep accumulating queued events for delivery after resuming. This does not promise history from before endpoint creation.
Incoming gateway receipt checks#
Gateway v3 posts to /api/gateway/webhook with X-Gateway-Event-Id, X-Gateway-Timestamp and X-Gateway-Signature: v1=<hex>. HMAC-SHA256 signs timestamp + "." + rawBody with GATEWAY_WEBHOOK_SECRET; the clock window is 300 seconds. Header/body ID equality, API version, live mode and body hash are checked.
An event ID reused with different body bytes is rejected. A signed notification triggers an authenticated GET of the exact operation. Order, method, currency, base, approved total and version are checked. Success requires fulfillment_ready=true, verification=provider_confirmed, the exact paid amount and no dispute; crypto also needs confirmed settlement evidence.
Ingress handling, receipts and merchant events are protected from duplicates. Old browser-only notification formats cannot establish successful payment. Merchant webhooks never contain card fields, bank codes, QR data, provider credentials or the gateway secret.
Telegram has a separate contract#
POST /api/telegram/webhook verifies X-Telegram-Bot-Api-Secret-Token, the update structure and sender. update_id protects against duplicate processing. This route handles sign-in, commands, support and moderation actions; it does not accept financial receipts.
The deployment owner configures the bot token, webhook secret, setWebhook registration and reachable HTTPS address. They are separate from the merchant endpoint signing secret.