Skip to content
Cipay
Esc
↑↓navigate↵open⌘Jpreview
On this page

Webhooks

Register, accept, inspect, and test signed Cipay deliveries safely.

Webhook endpoints belong to a merchant and scope (sandbox, production, or organization). Select the event types when the endpoint is created, then save the one-time secret returned by cipay.webhooks.create() or rotateSecret() in trusted storage. list() and retrieve() verify remote configuration but cannot reveal that secret again. Do not put endpoint registration behind a public browser-callable route.

const created = await cipay.webhooks.create({
  name: "Storefront receiver",
  url: "https://shop.example.com/api/cipay/webhooks",
  scope: "sandbox",
  selectedEventTypes: [
    WebhookEvent.CheckoutCompleted,
    WebhookEvent.OrderPaid,
    WebhookEvent.WebhookTest,
  ],
}, { idempotencyKey: "webhook_setup_001" });

// Save created.secret once. It is not returned by list() or retrieve().

Accept deliveries

// app/api/cipay/webhooks/route.ts
import { createCipayWebhookHandler } from "@cipay/client-sdk/api";
import { loadEndpoint } from "@/lib/webhooks";
import { persistCipayWebhookOnce } from "@/lib/cipay-webhook-store";

export const runtime = "nodejs";
export const POST = createCipayWebhookHandler({
  endpoint: loadEndpoint,
  persistOnce: persistCipayWebhookOnce,
});

Your server implementations have these contracts:

async function loadEndpoint() {
  return {
    merchantId: "your-verified-merchant-id",
    environment: "sandbox" as const,
    signingSecrets: [currentSecret, ...unexpiredOverlappingSecrets],
  };
}

async function persistCipayWebhookOnce(event, bodyHash) {
  return database.transaction(async (transaction) => {
    const claim = await transaction.cipayWebhookEvents.claim({
      eventId: event.id,
      bodyHash,
      event,
    });
    if (claim !== "created") return claim;

    await transaction.outbox.enqueue({
      kind: "cipay.webhook.received",
      eventId: event.id,
    });
    return "created";
  });
}

Replace the illustrative functions with your application code. claim must have a unique event-ID constraint and return conflict when that ID has a different stored SHA-256 hash. The claim and outbox insert must commit in the same transaction. Do not copy the literal merchant ID. Verify the merchant and environment against server-side endpoint configuration, never against an untrusted request parameter. The handler reads at most 1 MiB of raw bytes, verifies the HMAC and timestamp before parsing, then calls persistOnce. A successful claim receives 202; a storage failure receives 500 so Cipay can retry. An invalid signature or scope is 400, an oversized body 413, and missing endpoint configuration 503.

persistOnce(event, bodyHash) is required. Do not perform fulfillment, email, entitlement, or other application effects in the receiver. Process the durable outbox job separately and make that worker idempotent too. The receiver returns 202 for a new claim, 204 for a duplicate, and 409 for the same ID with different bytes. Store metadata and a SHA-256 body hash rather than raw payloads when a delivery inspector is all you need.

The factory returns a standard Request → Response function, so the same handler also fits Hono (app.post("/webhooks", (c) => handler(c.req.raw))) and Astro (export const POST = ({ request }) => handler(request)). With Express, mount express.raw({ type: "application/json", limit: "1mb" }), forward the untouched Buffer and the three x-cipay-* signature headers into a Web Request, then copy the returned status. Do not let JSON middleware consume or reserialize the body first.

Send a sandbox test

Subscribe an enabled sandbox endpoint to webhook.test, then use the server SDK:

const delivery = await cipay.webhooks.sendTest(endpointId, {
  idempotencyKey: "diagnostic_attempt_001",
});

Or call POST /v2/webhook-endpoints/{id}/test-deliveries with an authorized merchant API key and Idempotency-Key. The endpoint allows five new tests per hour, targets only that endpoint, signs webhook.test like a normal delivery, and never creates a payment. Retrying the same idempotency key returns the same delivery. Inspect its status and attempt history in Platform. Sending another test requires a new key.

sendTest does not require an endpoint revision because it creates a delivery rather than changing endpoint configuration. Endpoint edits, enable/disable operations, deletion, and secret rotation are revisioned: retrieve the endpoint first and send its latest revision plus an idempotency key. See webhook management.

Diagnose and replay

Platform shows safe attempt status, HTTP response code, and failure code. DNS, local-origin allowlist, and TLS certificate failures have separate safe codes; raw credentials, secrets, request bodies, and provider error text are not logged. Correct receiver or allowlist issues first, then replay a succeeded or failed delivery. An active delivery cannot be replayed; 409 explains that conflict. The required persistOnce unique event-ID claim returns 204 on redelivery so replay cannot enqueue a second effect.

After a committed checkout event, Cipay wakes the outbound-webhook worker for immediate processing. A one-minute cron is the fallback. Failed HTTP responses, timeouts, and retryable network failures begin with a 30-second backoff and double between attempts, subject to the 24-hour retry window. A receiver that returns 202 or another 2xx response is considered successful.

For local .localhost endpoints, set the exact HTTPS origin in CIPAY_SANDBOX_WEBHOOK_LOCAL_ORIGINS, ensure the Portless CA is trusted by the worker’s Node process (NODE_EXTRA_CA_CERTS=$HOME/.portless/ca.pem when launched outside Portless), and restart the worker after configuration changes. Never use rejectUnauthorized: false or a production private-address exception to make local tests pass.

Test at least changed body, bad signature, expired timestamp, mismatched event ID, wrong merchant/environment, oversized body, duplicate event ID, and temporary storage failure. No live gateway credential is needed for those tests.

Case Expected receiver response Durable effect
First valid event 202 One durable claim and outbox job
Claim or outbox failure 500 No acknowledgement; Cipay retries
Same signed event retried or replayed 204 No second claim or downstream job
Same ID with different valid bytes 409 No second job; investigate the conflict
Changed body, bad signature, stale timestamp, or wrong scope 400 None
Storage temporarily unavailable 500 No acknowledgement; Cipay retries

Test through your actual framework route, not only the verifier function, so raw-body handling is covered.