Outbound webhooks
Curobi pushes lifecycle events to an HTTPS endpoint you control, so your ERP, CRM, warehouse or Slack channel is told what happened instead of polling for it.
Webhooks are a Pro feature, on the same entitlement as the Developer API — the API is what you ask, webhooks are what you’re told. See Pricing & Plans.
Set them up either from the Webhooks page in your Curobi admin — no key required — or over the
API with a manage:webhooks key:
POST /api/admin/v1/webhooks/endpoints { url, events: [...] }
GET /api/admin/v1/webhooks/endpoints
GET /api/admin/v1/webhooks/endpoints/{id}
PATCH /api/admin/v1/webhooks/endpoints/{id} { events?, enabled? }
DELETE /api/admin/v1/webhooks/endpoints/{id}
GET /api/admin/v1/webhooks/deliveries ?endpoint_id= &status= &event= &cursor= &limit=
GET /api/admin/v1/webhooks/events # the catalogueThis is the only write the v1 API honours. It earns the exception by not being subscription data: an endpoint is part of the API’s own plumbing, so a key that can start and stop event delivery still cannot change a contract, a plan or a price. Errors use the same envelope and codes as the rest of the API.
Creating an endpoint
cURL
curl -sS -X POST https://app.curobi.com/api/admin/v1/webhooks/endpoints \
-H "Authorization: Bearer $CUROBI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/curobi",
"events": ["subscription.cancelled", "subscription.payment_failed"]
}'The response contains the signing secret, and it is the only time you will ever see it.
Don’t blindly retry this call. Idempotency keys aren’t honoured yet, so a retried create —
after a timeout, say — produces a second endpoint with its own secret, and your receiver starts
getting every event twice. If a create times out, GET /webhooks/endpoints and check before
retrying.
The secret is stored recoverably on our side, unlike an API key — a signature both sides can compute requires both sides to hold the same bytes, so there is no hashed form we could sign with. What we can do, and do, is never return it again from any endpoint at any scope. Lost it? Delete the endpoint and create a new one.
The delivery
POST /your/endpoint HTTP/1.1
Content-Type: application/json; charset=utf-8
User-Agent: CUROBI-Webhooks/1.0
X-Curobi-Signature: t=1755264000,v1=8f2c…
X-Curobi-Event-Id: evt_01K3Q8ZB4M7X2NPQRSTVWX
X-Curobi-Event-Type: subscription.cancelled
X-Curobi-Shop-Domain: acme.myshopify.com
X-Curobi-Api-Version: v1
{
"id": "evt_01K3Q8ZB4M7X2NPQRSTVWX",
"type": "subscription.cancelled",
"api_version": "v1",
"created_at": "2026-08-16T12:00:00.000Z",
"shop_domain": "acme.myshopify.com",
"data": {
"contract_id": "gid://shopify/SubscriptionContract/1234",
"customer_id": "gid://shopify/Customer/5678",
"previous_status": "ACTIVE",
"new_status": "CANCELLED"
}
}created_at is when the event happened, not when this attempt was sent. On a retry six hours
later it is unchanged — which is the point, because it timestamps the event and not the HTTP
request.
The headers duplicate the id and type for routing convenience. The body is the authority; the headers are not signed independently of it.
Verifying the signature
X-Curobi-Signature: t=<unix seconds>,v1=<hex hmac-sha256 of "<t>.<raw body>">The timestamp is inside the signed string. Signing the body alone would leave any captured
delivery replayable forever, because t could be rewritten freely.
cURL
# Not a receiver — a debugging tool. Recompute the signature for a delivery you
# captured, and compare it with the v1= value the header carried.
SECRET='…' # the endpoint's signing secret
T=1755264000 # the t= value from X-Curobi-Signature
BODY=$(cat delivery.json) # the raw body, byte for byte, exactly as received
printf '%s.%s' "$T" "$BODY" \
| openssl dgst -sha256 -hmac "$SECRET" -r \
| cut -d ' ' -f 1Four things those snippets are doing on purpose:
- The raw body, byte for byte. Not a re-serialised parsed object — two JSON encodings of the same object differ, and yours will not be ours. Every framework has a way to reach the bytes before the parser does; each tab above names its own.
- A freshness window. Five minutes is what we recommend and what our own verifier defaults to.
- Timing-safe comparison, and never a bare
==. Watch the length too: Node’stimingSafeEqualthrows on a length mismatch rather than returning false, and a thrown comparison is a crash, not a rejection. - Tolerating extra fields. A future
v2=may appear in the same header during a scheme migration, alongsidev1. Read the version you understand and ignore the rest; don’t assume the header has exactly two fields.
Delivery guarantees
At-least-once. Duplicates are possible and you have to handle them. A timeout that actually delivered is indistinguishable from one that didn’t, and retrying is the only honest response.
Store id and ignore repeats. It is stable across every retry of a delivery, and it is
derived from the event rather than randomly generated — so even a replay inside Curobi produces
the same id rather than a second delivery.
| Success | Any 2xx. A 202 from a receiver that queues the work is as good as a 200 |
| Redirects | Not followed. A 3xx is recorded as a failure — your URL moved and the endpoint needs updating |
410 Gone | Stops retrying immediately. The one status that means “retire this” |
| Other failures | Retried at 0s, 30s, 2m, 10m, 1h, 6h — 6 attempts over about 7.5 hours |
| Timeout | 10s per attempt. Acknowledge fast and do the work asynchronously |
| Ordering | Not guaranteed. Two events for one contract can arrive out of order; order them by created_at |
Sustained failure switches the endpoint off. Ten consecutive deliveries exhausting their full ladder disables it, and the counter resets on any success — so this means “your endpoint is dead now”, not “your endpoint has had a bad decade”. When it happens, the merchant sees a critical banner on the Webhooks page and an Integrations risk in the Risk Center.
Events that occurred while an endpoint was disabled are not replayed when it’s re-enabled.
Backfill them from the Admin API using updated_after.
The catalogue
GET /webhooks/events returns the subscribable list at runtime. Today:
| Event | Fires when |
|---|---|
subscription.created | A new contract is created |
subscription.renewed | A recurring charge succeeds |
subscription.renewal_reminder | An upcoming charge is approaching |
subscription.payment_failed | A charge fails and dunning starts |
subscription.payment_recovered | A dunning retry succeeds |
subscription.paused | Paused by customer, merchant or dunning |
subscription.cancelled | Cancelled; it will not bill again |
subscription.reactivated | A paused or cancelled contract is active again |
customer.payment_method_updated | The card behind the subscriptions changed |
customer.winback_started | A win-back campaign touched a churned customer |
gift.created | A gift subscription is purchased |
gift.sent | The claim email goes out to the recipient |
gift.claimed | The recipient claims the gift |
gift.expired | An unclaimed gift passes its window |
gift.ended | A gift’s final charge has run |
Subscription is opt-in and there is no wildcard. An event added to Curobi later reaches nobody until a merchant asks for it — a new event type that silently began arriving at an endpoint built for two of them is a breaking change delivered as a surprise.
Names that are not in this list (subscription.skipped, bundle.generated, …) are reserved.
Reserving a name is a promise about naming — that when skips ship they’ll be called that — not a
claim that anything is delivered. Subscribing to one is rejected with
invalid_request rather than accepted-and-never-fired, because
silence is the one failure a webhook consumer cannot debug.
Payloads carry no customer identity
No names, no email addresses, no shipping addresses, at any scope. data is built from an
allowlist of ids, statuses, dates and amounts.
The Admin API can return identity to a key holding read:customers; this surface never does, and
the difference is deliberate. There, you’re pulling your own data to a place you name per request.
Here, Curobi is pushing to a host on a standing instruction, and a URL configured eight months ago
may have changed hands since. Different risk, different answer.
Need the gift recipient’s email? Fetch the gift by gift_id with a read:customers key — one extra
call, and an auditable one.
The mechanism is an allowlist, not a redaction — which matters more than it sounds. With
{...data} minus a few keys, the next field added to an internal event payload would join every
merchant’s webhook without anyone touching the webhook code. With an allowlist, it is absent until
someone decides it belongs.
Endpoint URL rules
- https only. The signature proves who sent a payload; it does not keep it private.
- No credentials in the URL (
https://user:pass@…) — they would land in our logs and delivery records. Use the signing secret, or a token in the path or a query parameter. - No private, loopback, link-local or cloud-metadata addresses. Our servers make this request, so the field is validated as an untrusted target rather than as a preference.
- The URL can’t be edited. Changing it in place would keep the delivery history and the signing secret while pointing them at a different host. Delete and recreate: one extra call, and both facts stay honest.
- Ten active endpoints per shop.
A URL that breaks one of these is rejected with
invalid_request at create time.
The delivery log
GET /webhooks/deliveries pages on the same (created_at desc, id desc) keyset as every other
list, and is also rendered on the Webhooks page in the admin.
{
"data": [
{
"id": "clx…",
"endpoint_id": "clx…",
"event_id": "evt_01K3Q8ZB4M7X2NPQRSTVWX",
"event": "subscription.cancelled",
"status": "delivered",
"attempts": 2,
"response_code": 200,
"last_error": null,
"delivered_at": "2026-08-16T12:00:31.000Z",
"created_at": "2026-08-16T12:00:00.000Z"
}
],
"next_cursor": null
}status runs pending → delivered, or failed (an attempt failed, another is coming) →
exhausted (we stopped trying). Two words, because they’re two different support conversations.
Records are kept 30 days and pruned daily. response_code is your receiver’s status;
last_error is a message we composed — the response body is never read, let alone stored.
Building a good receiver
Verify the signature before anything else
Including before you parse the body. An unverified payload is a stranger’s JSON.
Return 2xx immediately, then work
The timeout is 10 seconds per attempt. Write the event to a queue and acknowledge; don’t do your fulfilment work inside the request.
Dedupe on id
At-least-once delivery means you will see repeats. A unique index on the event id is the whole fix.
Order by created_at, not by arrival
Two events for the same contract can arrive out of order. If a subscription.cancelled arrives
before the subscription.paused that preceded it, the timestamps are what tell you so.
Watch for the disabled banner
Ten consecutive exhausted deliveries switch your endpoint off, and nothing is replayed afterwards. Fix the receiver, re-enable, then backfill from the Admin API.
Uninstalling
Uninstalling Curobi disables every endpoint for the shop, in the same webhook that revokes every API key. Revoking credentials stops other people calling us; disabling endpoints stops us calling other people. The inbound direction would eventually fail closed on its own once the session is gone — the outbound direction never would, because we’re the one making the request.
Nothing is deleted, so reinstalling and re-enabling resumes delivery without re-setup. The same is true of a downgrade: the plan gate lives in the fan-out, so a shop that drops off Pro stops receiving events at the next published event, and re-upgrading resumes them.