Webhooks
Receive versioned, signed events through verified endpoints and a durable retry queue.
Webhooks are the authoritative way to synchronize an order after a checkout is created. Configure live endpoints in Dashboard → Developers. Test endpoints can also be managed with a test key carrying webhooks:read and webhooks:write. Test events go only to test endpoints; live events go only to live endpoints.
Event envelope
Every delivery has a stable event id and an integer schema version. Do not use the delivery time as the event time.
{
"id": "44d83e8f-...",
"schema_version": 1,
"event_type": "invoice.paid",
"merchant_id": "...",
"resource_id": "...",
"livemode": true,
"created_at": "2026-08-05T18:24:03.000Z",
"data": { "id": "...", "status": "paid", "...": "..." }
}| Header | Meaning |
|---|---|
paylink-signature | Timestamp plus one or more HMAC-SHA-256 signatures. |
paylink-event-id | Stable idempotency key for this event. |
paylink-version | Matches the body's schema_version. |
Accept known schema versions, ignore additive fields you do not use, and alert rather than guessing when a future version is unsupported.
Create and verify an endpoint
A new endpoint starts pending and disabled. Its signing secret appears only in the create response, so store it in your secret manager before leaving the response.
POST /api/v1/webhook-endpoints
Authorization: Bearer pk_test_...
Content-Type: application/json
{
"url": "https://merchant.example/webhooks/ulasend",
"subscribed_events": ["invoice.paid", "invoice.overpaid"]
}Use ["*"] to receive all event types. Bearer keys can list endpoints in their own mode, but bearer-key write operations are limited to test endpoints. Live creation, verification, rotation, and removal require an organization owner/admin dashboard session with MFA.
To enable the endpoint:
- Deploy the receiver with raw-body signature verification.
- When
event_typeiswebhook.endpoint_verification, return status2xxand the exact JSON below. The response must remain under 1 KiB. - Trigger the challenge with
POST /api/v1/webhook-endpoints/:id/verifyor the Verify action in Dashboard → Developers.
{ "challenge": "<event.data.challenge>" }Ulasend verifies the response using a constant-time comparison, then marks the endpoint verified and enabled. Verification failures leave it disabled and can be retried after the receiver is fixed.
Verify signatures using the raw body
The signature header has this form:
paylink-signature: t=<unix_ts>,v1=<hex_hmac>[,v1=<hex_hmac>...]
Each v1 is HMAC-SHA256(secret, "<t>.<raw_body>"). Read the request as bytes/text first. Parsing and re-serializing JSON changes the signed bytes and invalidates the signature. Reject the request if the timestamp differs from local time by more than 300 seconds.
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyUlasend(rawBody: string, header: string, secret: string) {
const fields = header.split(",").map((part) => part.trim());
const timestamps = fields
.filter((part) => part.startsWith("t="))
.map((part) => Number(part.slice(2)));
const signatures = fields
.filter((part) => part.startsWith("v1="))
.map((part) => part.slice(3));
if (timestamps.length !== 1 || !Number.isInteger(timestamps[0])) return false;
if (Math.abs(Date.now() / 1000 - timestamps[0]) > 300) return false;
const expected = createHmac("sha256", secret)
.update(String(timestamps[0]) + "." + rawBody)
.digest();
let valid = false;
for (const signature of signatures) {
const supplied = Buffer.from(signature, "hex");
const matches =
supplied.length === expected.length && timingSafeEqual(supplied, expected);
valid = matches || valid; // Evaluate every v1 candidate.
}
return valid;
}Verify before JSON parsing and before returning the verification challenge. Confirm that paylink-event-id equals the parsed body id, then use that id as the durable deduplication key.
Rotate a signing secret safely
POST /api/v1/webhook-endpoints/:id/rotate-secret
Content-Type: application/json
{ "grace_seconds": 86400 }Use this through the authenticated Dashboard action for a live endpoint. The new secret is shown once. The default overlap is 24 hours; set grace_seconds between 0 and 86400. During overlap, Ulasend signs each event with both valid secrets, so the header contains multiple v1 values. The verifier must check every value rather than assuming there is only one.
- Create the new secret and store it.
- Deploy the receiver using the new secret before overlap expires.
- Confirm successful deliveries, then let the previous secret expire.
Use a zero-second overlap only when the old secret is compromised and the receiver already has the replacement. Concurrent rotations are rejected instead of silently overwriting one another.
Delivery semantics
Ulasend commits the event and its endpoint jobs to a durable database outbox before network delivery. Failed requests retry with exponential backoff for up to eight attempts. A successful receiver should return a2xx within ten seconds and move slow work to its own queue.
Delivery is at least once. Duplicates and out-of-order events are expected whenever one delivery retries while a newer event succeeds. In one database transaction, insert the event id into a table with a unique constraint and apply the business update only if that insert succeeds. Do not deduplicate in memory.
begin; insert into processed_webhook_events (event_id) values (:paylink_event_id) on conflict do nothing; -- Apply the order update only when the insert above affected one row. commit;
Base fulfillment on the resource's state and your WooCommerce, Shopify, or custom order total—not on arrival order. Treat invoice.paid as fulfillable; route partial, overpaid, and manual-review states to your exception workflow.
Buyer and order matching
On invoice.paid, use external_reference, customer_email, metadata, and the invoice id. Store Ulasend's invoice id against your order when you create it. Before fulfillment, compare the webhook's confirmed amount, asset, and status with the authoritative order in your system.
Event types
Invoice invoice.created invoice.payment_detected invoice.confirming invoice.paid invoice.partially_paid invoice.overpaid invoice.expired invoice.manual_review Payout payout.requested payout.approved payout.rejected payout.submitted payout.confirmed payout.failed
Historical refund events remain in delivery logs for audit, but they cannot be selected for new endpoints. Ulasend does not offer a refund workflow.
Delivery data and retention
Ulasend records HTTP status and a bounded internal error code, but never stores your response body. Delivery attempts are retained for 30 days. Events are retained for 90 days and are not pruned while a delivery job is pending.