UlasendUlasendDocs

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", "...": "..." }
}
HeaderMeaning
paylink-signatureTimestamp plus one or more HMAC-SHA-256 signatures.
paylink-event-idStable idempotency key for this event.
paylink-versionMatches 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:

  1. Deploy the receiver with raw-body signature verification.
  2. When event_type is webhook.endpoint_verification, return status 2xx and the exact JSON below. The response must remain under 1 KiB.
  3. Trigger the challenge with POST /api/v1/webhook-endpoints/:id/verify or 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.

  1. Create the new secret and store it.
  2. Deploy the receiver using the new secret before overlap expires.
  3. 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.