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 continue retrying with exponential backoff, capped at twelve hours between attempts, while the endpoint remains eligible. Return a 2xx within ten seconds only after durably saving the event to your database or queue. Process slow work from that saved event.
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;
Progress events are optional. A completed payment can first arrive as invoice.paid, without earlier invoice.payment_detected or invoice.confirming notifications. Do not wait for those earlier events, or move a paid order backward when an older progress event arrives. Events contain snapshots; do not add their received amounts together.
Verify the amount before fulfillment
Save the invoice id, external_reference, and final amount_atomic returned when you create the invoice. For USDC on Base, one USDC equals 1000000 atomic units. Use integer strings and BigInt, not floating-point arithmetic or a two-decimal display amount.
With Require exact payment (zero underpayment tolerance), verified gross received must be at least that full final requested amount, including any passed processing fee and upward quote rounding. A short payment stays partial even after manual review. The invoice saves its tolerance at creation; later setting changes apply to future invoices. Historical invoices already marked paid are not relabeled, so always make the amount check in your own integration.
ACH and domestic wire invoices always require full payment. Crypto underpayment tolerance does not apply to bank invoices. A customer's bank submission or a processing update is not a paid event. See bank checkout for transfer instructions and later return or review handling.
To honor a positive saved tolerance, calculate the minimum in integer units as amount - (amount * bps / 10000n), where amount and bps are nonnegative BigInt values from amount_atomic and underpayment_tolerance_bps. Integer division rounds the permitted shortfall down. For 100 USDC with 100 basis points (1%), the minimum is 99 USDC. The comparison uses final USDC delivered after provider costs. Your own integration can require the full amount even if the merchant setting allows a shortfall.
| Field | Use |
|---|---|
amount_atomic on the invoice API | Final required payment amount. Save it with your order. |
underpayment_tolerance_bps on the invoice API | Tolerance saved for this invoice. Zero requires full payment; null identifies a historical invoice without a saved tolerance. A positive value defines the minimum using the formula above. Do not infer a tolerance for a null value. This field does not itself prove that payment was received. |
data.settlement.gross_amount_atomic | Actual verified USDC delivered in a provider settlement, before Ulasend's fee and retained excess, after provider conversion or network deductions. Current live provider settlement events include this for crypto, card-funded crypto, and bank payments. |
data.settlement.net_amount_atomic | Merchant credit after Ulasend's fee and retained excess. Do not compare this net amount to the gross invoice requirement. |
settlement_allocation.remaining_order_amount_atomic | Must be zero for exact-payment fulfillment when present. It can remain positive for an accepted shortfall within tolerance; missing funds are never credited. Also compare gross received to the full quote or your explicitly accepted minimum. |
- Verify the signature and durably deduplicate the event id.
- Match the merchant, invoice id, and order reference, and require both the event and invoice to be in live mode for a real order.
- Fetch
GET /api/v1/invoices/{invoiceId}to confirm the current state. Requirepaidoroverpaid, the expected settlement asset and network, and enough verified funds. For USDC on Base, checkUSDC,BASEandUSDC_BASE. Payer source instructions may use another asset. - Compare gross received to your saved required amount using integer units, or to the saved tolerance minimum if you accept shortfalls. For full-payment fulfillment, require zero remaining allocation when present. For tolerated payments, verify any remaining allocation is consistent with the actual shortfall. The invoice API's
received_amountis a decimal string; convert it exactly to six-decimal atomic units for USDC. Missing or inconsistent payment evidence requires reconciliation, not fulfillment. - Fulfill the order once in durable storage. Two different events for one invoice must not create two fulfillments.
On current provider-routed invoices, an overpayment can emit invoice.paid; other paths can emit invoice.overpaid. Gross includes the verified excess, while the allocation identifies the amount applied to the order and any excess retained by Ulasend. Route partial and manual-review states to your exception workflow. A browser return or a Paid label alone is not sufficient proof of the exact amount received.
Recover missed notifications
Open Dashboard → Developers → Notifications awaiting delivery to see retained events that have not reached an eligible endpoint. If an event was created before your endpoint existed, select your verified endpoint and explicitly queue that event. Adding a new endpoint does not silently send it your historical events. Live recovery requires an organization owner or administrator session with two-factor authentication.
Recovery preserves the original event id. Your receiver must deduplicate it in durable storage, including after a restart. Queuing or receiving a notification does not create another payment or change the merchant balance.
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. Successfully delivered events are normally retained for 90 days. Undelivered events, including events created without an endpoint, remain available for recovery. Disabling or removing an endpoint stops delivery to that endpoint; it does not authorize forwarding its events elsewhere.