Skip to content

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

Request your server receives
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.

  1. Read the body as raw bytes. Parsing and re-serialising the JSON changes them and breaks the signature.
  2. Split Ravelo-Signature on commas into name=value pairs. Keep t and every v1, and ignore names you do not know.
  3. Reject the delivery when t is more than five minutes from your clock.
  4. Compute the HMAC and compare it with each v1 in 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.
  5. Only then parse the JSON.

The SDKs do all of this in one call. Their receivers are in the quickstart:

webhook-server.ts

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:

verify-manually.ts

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.

Test vector
{
  "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.

  1. 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 new secret, shown once, and a new key_id. previous_key_id and previous_secret_expires_at describe the old secret while it still signs.
  2. 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.
  3. 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; 0 retires it at once. Call POST /v1/webhook-endpoints/{id}/end-rotation to 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 POST
Header during an overlap, newest secret first
Ravelo-Signature: t=1760000000,kid=whk_new,v1=<hex under the new secret>,kid=whk_old,v1=<hex under the old secret>
  • Each kid labels the v1 right 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 reads t and the v1 values, and one of them matches its secret. Only a parser that reads just the first v1 would 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=false from the dashboard, or a test key).
  • Use kid when 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.

Rotation test vector
{
  "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 2xx within 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 in Ravelo-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 with POST /v1/events/{id}/replay.

Endpoints

  • The URL must be public https without credentials. Private, loopback and link-local addresses are refused when you save and again on every connection.
  • An empty event_types subscribes 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:manage scope.
  • POST /v1/webhook-endpoints/{id}/test sends a webhook.test event to that endpoint, and GET /v1/webhook-endpoints/{id}/deliveries shows 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

FamilyMeaning
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_heldA 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_returnedThe held excess reached the payer. Returns have no events of their own; the payment's overpay_status carries their state.
webhook.testSent 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.