Reference
Webhooks
Ravelo tells your server when something happens to a payment, payout or refund by POSTing a signed event to an endpoint you register. Trust a payment only when a verified event says so.
A delivery
POST /webhooks/ravelo
Content-Type: application/json
User-Agent: Ravelo-Webhooks/1
Ravelo-Event-Id: 0192f1e2-...
Ravelo-Event-Type: payment_intent.succeeded
Ravelo-Signature: t=1760000000,kid=whk_3f9a1c7d52e0,v1=<hex>
{ "id": "0192f1e2-...", "type": "payment_intent.succeeded", "created_at": "...", "data": { ... } }data is a snapshot of the payment intent, payout or refund at the moment of the event, in the same shape the API returns for it.
Verify the signature
v1 is the hex HMAC-SHA256 of <t>.<raw body> under the endpoint's whsec_ secret, where t is the Unix time in the header. kid is the key id of the secret that made the v1 after it; the endpoint shows it as key_id.
- Read the body as raw bytes. Parsing and re-serialising the JSON changes them and breaks the signature.
- Split
Ravelo-Signatureon commas intoname=valuepairs. Keeptand everyv1, and ignore names you do not know. - Reject the delivery when
tis more than five minutes from your clock. - Compute the HMAC and compare it with each
v1in constant time. Any match is valid: during a secret rotation the header carries one signature per secret, so a verifier holding either secret accepts the delivery. - Only then parse the JSON.
The SDKs do all of this in one call. Their receivers are in the quickstart:
TypeScript
import { createServer } from "node:http";
import { verifyWebhook, WebhookSignatureError } from "@ravelo/sdk";
const secret = process.env.RAVELO_WEBHOOK_SECRET!;
const handled = new Set<string>(); // keep this in your database; deliveries can repeat
createServer(async (req, res) => {
if (req.method !== "POST" || req.url !== "/webhooks/ravelo") {
res.writeHead(404).end();
return;
}
// Verify the bytes exactly as they arrived; parsing and re-serialising the JSON would break the signature.
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
try {
const event = await verifyWebhook(Buffer.concat(chunks), req.headers["ravelo-signature"] as string | undefined, secret);
if (!handled.has(event.id)) {
handled.add(event.id);
console.log("verified", event.type, event.id);
if (event.type === "payment_intent.succeeded") console.log("paid:", event.data.id);
}
res.writeHead(200).end();
} catch (err) {
console.error("rejected:", err instanceof Error ? err.message : err);
res.writeHead(err instanceof WebhookSignatureError ? 400 : 500).end();
}
}).listen(3000, () => console.log("listening on http://localhost:3000/webhooks/ravelo"));Python
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
from ravelo import SIGNATURE_HEADER, WebhookSignatureError, verify_webhook
SECRET = os.environ["RAVELO_WEBHOOK_SECRET"]
handled = set() # keep this in your database; deliveries can repeat
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/webhooks/ravelo":
self.send_response(404)
self.end_headers()
return
# Verify the bytes exactly as they arrived; parsing and re-serialising the JSON would break the signature.
raw = self.rfile.read(int(self.headers.get("Content-Length", 0)))
try:
event = verify_webhook(raw, self.headers.get(SIGNATURE_HEADER), SECRET)
except WebhookSignatureError as err:
print("rejected:", err)
self.send_response(400)
self.end_headers()
return
if event["id"] not in handled:
handled.add(event["id"])
print("verified", event["type"], event["id"])
if event["type"] == "payment_intent.succeeded":
print("paid:", event["data"]["id"])
self.send_response(200)
self.end_headers()
print("listening on http://localhost:3000/webhooks/ravelo")
HTTPServer(("", 3000), Handler).serve_forever()Without an SDK, this is the whole algorithm:
TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";
/** Verifies a Ravelo-Signature header without the SDK. `rawBody` is the request body exactly as received. */
export function verifyManually(rawBody: string, header: string, secret: string, nowMs = Date.now(), toleranceSeconds = 300): boolean {
// kid and any name you do not know are ignored; any v1 may match, which is how a rotation overlap verifies.
const parts = header.split(",").map((item) => item.trim().split("=", 2) as [string, string]);
const t = parts.find(([k]) => k === "t")?.[1];
const signatures = parts.filter(([k]) => k === "v1").map(([, v]) => v);
if (!t || !/^\d+$/.test(t) || signatures.length === 0) return false;
if (Math.abs(Math.floor(nowMs / 1000) - Number(t)) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return signatures.some((s) => s.length === expected.length && timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}Python
import hashlib
import hmac
import time
def verify_manually(raw_body: bytes, header: str, secret: str, now: float = None, tolerance_seconds: int = 300) -> bool:
"""Verifies a Ravelo-Signature header without the SDK. raw_body is the request body exactly as received."""
# kid and any name you do not know are ignored; any v1 may match, which is how a rotation overlap verifies.
pairs = [item.strip().partition("=") for item in header.split(",")]
t = next((v for k, _, v in pairs if k == "t"), None)
signatures = [v for k, _, v in pairs if k == "v1"]
if not t or not t.isdigit() or not signatures:
return False
if abs(int(now if now is not None else time.time()) - int(t)) > tolerance_seconds:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(s, expected) for s in signatures)Check your verifier against this fixture, which the server and both SDKs are tested against. Verified at the timestamp, it must accept; change one byte of the body and it must reject.
{
"secret": "whsec_test_vector_do_not_use",
"timestamp": 1760000000,
"body": "{\"id\":\"evt_01\",\"type\":\"payment_intent.succeeded\",\"data\":{\"id\":\"pi_01\"}}",
"header": "t=1760000000,v1=93754a21211b5c47eb3a6ffbc75604bc021b145c471d2413146a81443435e6f9"
}Rotate the signing secret
Rotate when a secret may have leaked, when someone who knew it leaves, or on a schedule. Rotation never drops a delivery: for an overlap period every delivery is signed with both the new secret and the one it replaces.
- Call
POST /v1/webhook-endpoints/{id}/rotate-secret(or choose Rotate signing secret in the dashboard, which asks for your second factor). The response is the endpoint with its newsecret, shown once, and a newkey_id.previous_key_idandprevious_secret_expires_atdescribe the old secret while it still signs. - Deploy the new secret to your receivers. Until you do, receivers keep verifying with the old one; afterwards they verify with the new one. Nothing else changes.
- The old secret stops signing when the overlap ends, which is 24 hours by default. Send
overlap_seconds(0 to 604800) to choose another length;0retires it at once. CallPOST /v1/webhook-endpoints/{id}/end-rotationto end the overlap early, for example once every receiver has switched.
curl
# Issue a new secret and keep the old one signing for one hour (the default is 24 hours).
# The response is the endpoint with its new "secret", shown once.
curl -sS "$RAVELO_API/v1/webhook-endpoints/$ENDPOINT_ID/rotate-secret" \
-H "Authorization: Bearer $RAVELO_TEST_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "overlap_seconds": 3600 }'
# Once every receiver uses the new secret, stop the old one signing:
curl -sS "$RAVELO_API/v1/webhook-endpoints/$ENDPOINT_ID/end-rotation" \
-H "Authorization: Bearer $RAVELO_TEST_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-X POSTRavelo-Signature: t=1760000000,kid=whk_new,v1=<hex under the new secret>,kid=whk_old,v1=<hex under the old secret>- Each
kidlabels thev1right after it. The two signatures cover the same<t>.<raw body>and differ only in the secret. - A receiver that predates key ids, or ignores
kid, still works: it readstand thev1values, and one of them matches its secret. Only a parser that reads just the firstv1would verify under the new secret alone, so deploy the new secret before the old one stops being enough. - Rotating again inside an overlap retires the older secret immediately. Every rotation, early end and expiry is recorded in the audit log, and test-mode endpoints rotate the same way (
livemode=falsefrom the dashboard, or a test key). - Use
kidwhen your receiver holds more than one secret and wants the right one without trying each. Replays and retries are signed again at send time, so a delivery retried after a rotation carries the keys current then.
This fixture is a delivery signed during a rotation. Verified at the timestamp, it must be accepted under each of the two secrets, and rejected under any other.
{
"keys": [
{
"id": "whk_vector_new",
"secret": "whsec_test_vector_new_do_not_use"
},
{
"id": "whk_vector_old",
"secret": "whsec_test_vector_old_do_not_use"
}
],
"timestamp": 1760000000,
"body": "{\"id\":\"evt_02\",\"type\":\"payment_intent.succeeded\",\"data\":{\"id\":\"pi_02\"}}",
"header": "t=1760000000,kid=whk_vector_new,v1=d3b9d8a489990323e15d0f7d8cc899aea2c9946c93325a745224d2c0cb34611e,kid=whk_vector_old,v1=f6b1bfb2e013cf142d25a4b519fdff825161b14388a882caf6c0ebcf658069ac"
}Respond and retry
- Any
2xxwithin 10 seconds acknowledges the delivery. Do the slow work after you answer. - Anything else, a timeout included, is retried with backoff starting at 30 seconds and doubling up to 4 hours, for 72 hours (about 26 attempts). The last attempt falls at the 72-hour mark, then the delivery is marked failed.
- Delivery is at least once. Keep the event
id(also inRavelo-Event-Id) and ignore one you have handled. - Do not rely on order. After a retry an older event can arrive after a newer one; fetch the current state when it matters.
- Missed events stay in the log. Read them with
GET /v1/events(pagination) or ask for a redelivery withPOST /v1/events/{id}/replay.
Endpoints
- The URL must be public
httpswithout credentials. Private, loopback and link-local addresses are refused when you save and again on every connection. - An empty
event_typessubscribes to every type. At most 10 endpoints may be active. - The signing secret is returned once, when you create the endpoint or rotate its secret. Owners, admins and developers may manage endpoints; an API key needs the
webhooks:managescope. POST /v1/webhook-endpoints/{id}/testsends awebhook.testevent to that endpoint, andGET /v1/webhook-endpoints/{id}/deliveriesshows each attempt's status code or error.
Test mode
Endpoints you register with a rvk_test_ key belong to the sandbox and receive the events of test payments, with "livemode": false in data. They are signed and retried exactly like live ones, with their own secret. See test mode.
Event types
| Family | Meaning |
|---|---|
| payment_intent.* | A payment request moved: created, waiting for payment, processing, partially funded, succeeded, expired, cancelled, fulfilled, or its compliance disposition changed. payment_intent.instructions_ready fires once when instructions that were pending (the provider session was still opening) can be shown to the payer; its data carries them. A request whose instructions cannot be shown is cancelled instead, with instructions.unavailable_reason set. |
| payout.* | A payout is awaiting approval, in review, submitted, completed, failed or cancelled. |
| refund.* | The same lifecycle for a refund. |
| payment_intent.overpayment_held | A payer sent more than the amount due, past the tolerance. The excess is held for the payer outside your balance; data.return_by is your deadline to return it with POST /v1/overpayment-returns. |
| payment_intent.overpayment_returned | The held excess reached the payer. Returns have no events of their own; the payment's overpay_status carries their state. |
| webhook.test | Sent by the test-event call, to one endpoint only. |
This is the full list in the current contract. New types can be added in a minor version, so ignore types you do not handle.
| Type |
|---|
| payment_intent.created |
| payment_intent.requires_payment |
| payment_intent.processing |
| payment_intent.partially_funded |
| payment_intent.succeeded |
| payment_intent.expired |
| payment_intent.cancelled |
| payment_intent.fulfilled |
| payment_intent.disposition_changed |
| payment_intent.instructions_ready |
| payment_intent.overpayment_held |
| payment_intent.overpayment_returned |
| payout.awaiting_approval |
| payout.pending_review |
| payout.submitted |
| payout.completed |
| payout.failed |
| payout.cancelled |
| refund.awaiting_approval |
| refund.pending_review |
| refund.submitted |
| refund.completed |
| refund.failed |
| refund.cancelled |
| webhook.test |
Fulfil on payment_intent.succeeded
payment_intent.succeeded means verified funds cover the amount due, or you accepted a short payment, and the funds are available. Deliver the order, then call POST /v1/payment-intents/{id}/fulfil.