1. Set up your endpoint
Outbound webhooks are configured per shop, inside the Sublyra admin.
1 Open the Sublyra admin and go to Settings → Developer.
2 In the Webhooks card, paste your endpoint URL — the public address of your receiver, e.g. https://hooks.yourstore.com/sublyra.
3 Note the Signing secret shown on the same card. It's generated automatically for your shop (a whsec_… string) and you'll use it to verify every delivery. Store it like a password.
4 Tick the events you want — only checked topics are sent — and click Save webhooks.
From then on, each subscribed event is queued the moment it happens and POSTed to your endpoint by the job runner, typically within a minute.
https:// only. http://, localhost, private/loopback and link-local IPs are rejected at save time (an SSRF guard) — so http://localhost:3000 won't work even on a dev store. To inspect payloads while building, point the endpoint at a request bin like webhook.site.
2. Topics and payloads
Subscribe to any combination of these eight topics. The data object of each delivery carries the fields listed below. IDs: contract_id and order_id are Shopify gids; customer_id is Sublyra's internal customer ID (matching id in the REST API).
| Topic | Fires when | data fields |
|---|---|---|
subscription.created | A new subscription contract starts. | contract_id, customer_id, status, interval, interval_count, next_billing_date, selling_plan_id |
subscription.updated | A contract's status or next billing date changes. | contract_id, customer_id, status, next_billing_date |
subscription.cancelled | A contract is cancelled. | contract_id, customer_id, churn_type (voluntary or involuntary) |
order.charged | A renewal or checkout charge succeeds. | order_id, contract_id, customer_id, cycle_number, total_cents, attempt_count |
order.failed | A billing attempt is declined. | order_id, contract_id, customer_id, cycle_number, total_cents, attempt_count, reason, next_retry_at, retries_exhausted |
order.recovered | A previously failed order is charged successfully by dunning. | Same fields as order.charged |
loyalty.reward_earned | A loyalty, streak or referral reward is earned. | Always source (loyalty · streak · referral) and customer_id; plus source-specific fields such as credit_cents, milestone, cycles, reward_cents, discount_code |
cancel_flow.completed | A subscriber finishes the cancellation flow. | contract_id, customer_id, reason_id, offer_type, accepted, saved |
A few practical notes:
order.failedfires on every declined attempt, not just the final one. Checkretries_exhausted:falsemeans dunning will retry (next_retry_atsays when);truemeans the retries are done.order.recoveredinstead oforder.chargedtells you the order had failed at least once before succeeding — useful for measuring dunning performance.loyalty.reward_earnedfields vary bysource— branch on that field before reading the rest.
3. The delivery envelope
Every delivery is a POST with a JSON body in a fixed envelope:
POST https://hooks.yourstore.com/sublyra
Content-Type: application/json
X-Sublyra-Topic: order.failed
X-Sublyra-Signature: sha256=9f2ab41c7d3e58f0…
{
"id": "whd_3f8a1c2e4b5d69708f1a2b3c4d5e6f70",
"topic": "order.failed",
"created_at": "2026-08-24T12:00:00.000Z",
"shop": "your-store.myshopify.com",
"data": {
"order_id": null,
"contract_id": "gid://shopify/SubscriptionContract/1001",
"customer_id": "clx9k2m4p0002efgh",
"cycle_number": 3,
"total_cents": 3780,
"attempt_count": 2,
"reason": "insufficient_funds",
"next_retry_at": "2026-08-27T12:00:00.000Z",
"retries_exhausted": false
}
}
| Field / header | Meaning |
|---|---|
id | Unique delivery ID (whd_…). Retries resend the same ID — use it to dedupe. |
topic / X-Sublyra-Topic | The event topic, both in the body and as a header (route on whichever is easier). |
created_at | When the event was queued (ISO 8601 UTC), not when the attempt was sent. |
shop | Your shop's myshopify.com domain — relevant if one endpoint serves several shops. |
data | The topic-specific payload from the table above. |
X-Sublyra-Signature | sha256= + hex HMAC-SHA256 of the raw request body, keyed with your signing secret. See the next section. |
4. Verify the signature
Always verify before trusting a payload — the endpoint is public, so anyone can POST to it. The check: compute the HMAC-SHA256 of the raw, unparsed request body with your signing secret, hex-encode it, and compare it to the X-Sublyra-Signature header.
1 Read the raw body bytes — before any JSON parsing or middleware that re-serializes the body.
2 Compute HMAC-SHA256(secret, rawBody) and hex-encode the digest.
3 Compare against the header value after the sha256= prefix, using a constant-time comparison. Reject mismatches with a 401.
Node.js (Express) example
import crypto from 'node:crypto'
import express from 'express'
const app = express()
// Raw body required — JSON parsing first would break the signature.
app.post('/sublyra', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.get('X-Sublyra-Signature') ?? ''
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.SUBLYRA_WEBHOOK_SECRET)
.update(req.body) // raw Buffer
.digest('hex')
const valid = signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
if (!valid) return res.sendStatus(401)
const event = JSON.parse(req.body.toString('utf8'))
// Deduplicate on event.id, then handle event.topic …
res.sendStatus(200)
})
5. Retries and the delivery log
A delivery succeeds when your endpoint answers any 2xx within 10 seconds. Anything else — non-2xx, timeout, connection error — is a failure, and Sublyra retries automatically:
| Attempt | Wait before next attempt |
|---|---|
| 1 → 2 | 2 minutes |
| 2 → 3 | 4 minutes |
| 3 → 4 | 8 minutes |
| 4 → 5 | 16 minutes |
| After 5 failed attempts | Delivery is marked Dead — automatic retries stop |
Every retry resends the stored body byte-for-byte: the delivery id, the created_at timestamp and the signature stay identical across attempts. Your receiver can safely dedupe on id.
The delivery log
Settings → Developer → Recent deliveries shows the last 20 deliveries with topic, status, attempt count, the last HTTP status code your endpoint returned, and any error:
| Status | Meaning |
|---|---|
| Pending | Queued; the next job run will attempt it. |
| Retrying | An attempt failed; automatic backoff is in progress. |
| Delivered | Your endpoint answered 2xx. |
| Dead | All 5 attempts failed. Fix the endpoint, then use the Retry button to re-queue it manually. |
endpoint responded HTTP 500 means your receiver threw — check its own logs.
6. Receiver best practices
- Verify first, always. Reject bad signatures before touching
data. - Answer fast, work later. Return
200as soon as you've durably stored the event; do slow work (API calls, emails) in a background job. The 10-second timeout counts the whole round trip. - Dedupe on
id. Retries and manual re-sends repeat the same delivery ID; process each ID once. - Stay idempotent anyway. Network conditions can deliver the same event more than once even outside retries — handlers that can run twice safely are handlers you don't have to debug.
- Reconcile with the API. Webhooks tell you something changed; the REST API lets you pull full state if you suspect a gap after downtime.
7. FAQ
How fast are deliveries?
Events are queued the moment they happen and delivered by the job runner on its next pass — typically within a minute. They never delay the customer-facing action that caused them.
Can I get the same event twice?
Yes — retries resend failed deliveries, and a dead delivery can be re-sent manually. Every resend carries the same id, so dedupe on it.
Can I have multiple endpoints?
One endpoint per shop, with per-topic checkboxes. If several systems need events, fan out from your receiver or use Shopify Flow for the in-Shopify cases.
What happens to events while my endpoint is down?
They're retried with backoff for about half an hour across 5 attempts, then marked dead. Dead deliveries stay in the log with a manual Retry button — nothing is silently dropped.