Webhooks
Subsie pushes a signed event to your server for every meaningful transition — sessions completing, payments settling, subscriptions changing state. Verify the signature, handle the event idempotently, and respond fast; Subsie takes care of retries.
Event types
| Event | Fires when | data.object |
|---|---|---|
checkout.session.completed | A checkout session is paid and verified on-chain. | checkout.session |
payment.succeeded | A payment settles — one-time or recurring. | payment |
payment.failed | A recurring charge attempt fails. | payment |
payment.refunded | A refund is recorded (partial or full) against a payment. | payment |
subscription.created | A buyer completes a subscription checkout. | subscription |
subscription.past_due | A charge failed and dunning retries are scheduled. | subscription |
subscription.expired | Retries are exhausted and the subscription lapses. | subscription |
subscription.canceled | The subscriber cancels on-chain or via the portal. | subscription |
Endpoints are mode-scoped: an endpoint created with a test key receives only test events (livemode: false), a live endpoint only live events. The objects inside data.object are exactly the shapes from the API reference.
The event envelope
POST https://example.com/webhooks/orven
Content-Type: application/json
User-Agent: Orven-Webhooks/1.0
Orven-Signature: t=1781234567,v1=5f8c1f2e9a...
{
"id": "evt_Xc4nQv8MfYwRtnKpBZjdLh9F",
"type": "payment.succeeded",
"created_at": "2026-06-12T10:05:00.000Z",
"data": {
"object": {
"id": "pay_e8aiLUZADe3yCMh5RsAecHV3",
"object": "payment",
"livemode": true,
"status": "confirmed",
"amount": "0.1",
"fee_amount": "0.002",
"merchant_amount": "0.098",
"...": "..."
}
}
}Verifying signatures
Every delivery carries an Orven-Signature header. The signature is an HMAC-SHA256 over the timestamp and the raw request body, keyed with your endpoint secret (whsec_…, returned once when the endpoint is created):
Orven-Signature: t=<unix seconds>,v1=<hex>
v1 = HMAC_SHA256(secret, "<t>.<raw body>")Reject deliveries whose timestamp is outside your tolerance window (default five minutes) and compare signatures in constant time. With the SDK this is one call:
import express from "express";
import { Orven, OrvenWebhookVerificationError } from "@orven/node";
app.post(
"/webhooks/orven",
express.raw({ type: "application/json" }), // keep the raw bytes
(req, res) => {
let event;
try {
event = Orven.webhooks.constructEvent({
payload: req.body,
header: req.get("Orven-Signature") ?? "",
secret: process.env.ORVEN_WEBHOOK_SECRET!,
toleranceSeconds: 300, // default
});
} catch (error) {
if (error instanceof OrvenWebhookVerificationError) {
return res.status(400).send("invalid signature");
}
throw error;
}
switch (event.type) {
case "checkout.session.completed":
// fulfill the order
break;
case "subscription.past_due":
// notify the customer
break;
}
res.status(200).end();
},
);Without the SDK, verification is a few lines of Node:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyOrvenSignature(
rawBody, // string | Buffer — the bytes exactly as received
header, // the Orven-Signature header value
secret,
toleranceSeconds = 300,
) {
const parts = Object.fromEntries(
header.split(",").map((pair) => pair.split("=", 2)),
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const provided = Buffer.from(parts.v1 ?? "", "hex");
const wanted = Buffer.from(expected, "hex");
return provided.length === wanted.length && timingSafeEqual(provided, wanted);
}express.json()) will change the byte sequence and every signature check will fail. Mount a raw-body parser on the webhook route.Delivery and retries
| Property | Behavior |
|---|---|
| Success | Any 2xx response within 10 seconds. |
| Retries | Up to 5 attempts with exponential backoff (30s base, doubling per attempt). |
| Failure | After the 5th failed attempt the event is marked failed and surfaces on the dashboard and the webhook events API. |
| Ordering | Not guaranteed. Use created_at and your own state machine; do not assume events arrive in sequence. |
| Duplicates | Possible — delivery is at-least-once. Deduplicate by event id. |
Recommended handler shape:
1. Verify the signature before reading anything else.
2. Check event.data.object.livemode matches the environment.
3. Deduplicate by event.id (store processed ids).
4. Acknowledge with 200 immediately; do slow work async.
5. Treat webhooks as notifications — re-fetch via the API
if you need guaranteed-current state.Test events and redelivery
You can exercise your handler end to end without making a payment, and replay any delivery after fixing your endpoint. Both ride the normal pipeline — same signature, same retries, same observability.
curl https://pay.example.com/api/v1/webhook_endpoints/we_.../test \
-H "Authorization: Bearer $ORVEN_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "type": "checkout.session.completed" }'
# or with the SDK:
await orven.webhookEndpoints.sendTest("we_...", { type: "checkout.session.completed" });The data.object is synthetic but matches the production serializer shapes exactly, with internally consistent fee math and a livemode flag matching the endpoint's mode. Test ids (like cs_test000000000000000000) never resolve through the retrieval APIs, so a handler that re-fetches by id sees a 404 — fulfillment logic keyed to real objects cannot be tricked. Each send gets a fresh envelope id, so repeated tests are not swallowed by your dedupe.
curl -X POST https://pay.example.com/api/v1/webhook_events/evt_.../resend \
-H "Authorization: Bearer $ORVEN_SECRET_KEY"
# or with the SDK:
await orven.webhookEvents.resend("evt_...");Redelivery creates a fresh delivery row with a full retry budget, carrying the original payload byte-for-byte — including the same envelope id. If your handler deduplicates by event id (it should), both deliveries count as one logical event. The response carries resend_of pointing at the original, whose delivery history stays untouched. Redelivery is refused with 409 while the original or an existing redelivery is still pending— the worker is already retrying it. Both actions are also one click on the dashboard's Webhooks page.
Observability
Every delivery attempt is recorded. The Webhooks page in the dashboard shows recent deliveries per endpoint, and GET /api/v1/webhook_events exposes the same data programmatically — status, attempt count, last response code, last error, and the next retry time. Filter by status=failed to find events your endpoint never accepted.