Skip to Content
Your subscription data now goes where you need it: the Curobi Developer API feeds your warehouse, ERP or CRM, and webhooks tell you the moment something changes — read the API docs →

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 catalogue

This 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 -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.

# 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 1

Four things those snippets are doing on purpose:

  1. 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.
  2. A freshness window. Five minutes is what we recommend and what our own verifier defaults to.
  3. Timing-safe comparison, and never a bare ==. Watch the length too: Node’s timingSafeEqual throws on a length mismatch rather than returning false, and a thrown comparison is a crash, not a rejection.
  4. Tolerating extra fields. A future v2= may appear in the same header during a scheme migration, alongside v1. 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.

SuccessAny 2xx. A 202 from a receiver that queues the work is as good as a 200
RedirectsNot followed. A 3xx is recorded as a failure — your URL moved and the endpoint needs updating
410 GoneStops retrying immediately. The one status that means “retire this”
Other failuresRetried at 0s, 30s, 2m, 10m, 1h, 6h — 6 attempts over about 7.5 hours
Timeout10s per attempt. Acknowledge fast and do the work asynchronously
OrderingNot 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:

EventFires when
subscription.createdA new contract is created
subscription.renewedA recurring charge succeeds
subscription.renewal_reminderAn upcoming charge is approaching
subscription.payment_failedA charge fails and dunning starts
subscription.payment_recoveredA dunning retry succeeds
subscription.pausedPaused by customer, merchant or dunning
subscription.cancelledCancelled; it will not bill again
subscription.reactivatedA paused or cancelled contract is active again
customer.payment_method_updatedThe card behind the subscriptions changed
customer.winback_startedA win-back campaign touched a churned customer
gift.createdA gift subscription is purchased
gift.sentThe claim email goes out to the recipient
gift.claimedThe recipient claims the gift
gift.expiredAn unclaimed gift passes its window
gift.endedA 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 pendingdelivered, 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.

Last updated on