Outbound Webhooks

Signed JSON events, POSTed to your endpoint · Retried until they land

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.

Endpoint rules. Public 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.
Plan availability. Outbound webhooks require the Pro plan, or the API & webhooks add-on ($19.99/mo).
Fire-and-forget, in a good way. Event delivery never blocks the thing that caused it: a slow or down endpoint can't delay a checkout, a renewal charge or a webhook from Shopify. Events are persisted first, then delivered asynchronously.

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

TopicFires whendata fields
subscription.createdA new subscription contract starts.contract_id, customer_id, status, interval, interval_count, next_billing_date, selling_plan_id
subscription.updatedA contract's status or next billing date changes.contract_id, customer_id, status, next_billing_date
subscription.cancelledA contract is cancelled.contract_id, customer_id, churn_type (voluntary or involuntary)
order.chargedA renewal or checkout charge succeeds.order_id, contract_id, customer_id, cycle_number, total_cents, attempt_count
order.failedA billing attempt is declined.order_id, contract_id, customer_id, cycle_number, total_cents, attempt_count, reason, next_retry_at, retries_exhausted
order.recoveredA previously failed order is charged successfully by dunning.Same fields as order.charged
loyalty.reward_earnedA 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.completedA subscriber finishes the cancellation flow.contract_id, customer_id, reason_id, offer_type, accepted, saved

A few practical notes:

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 / headerMeaning
idUnique delivery ID (whd_…). Retries resend the same ID — use it to dedupe.
topic / X-Sublyra-TopicThe event topic, both in the body and as a header (route on whichever is easier).
created_atWhen the event was queued (ISO 8601 UTC), not when the attempt was sent.
shopYour shop's myshopify.com domain — relevant if one endpoint serves several shops.
dataThe topic-specific payload from the table above.
X-Sublyra-Signaturesha256= + 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)
})
Sign over the raw body, not a re-serialized object. JSON key order and whitespace change the bytes, and the signature covers the bytes exactly as sent. Framework helpers that parse before your handler runs are the number-one cause of "signature never matches".

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:

AttemptWait before next attempt
1 → 22 minutes
2 → 34 minutes
3 → 48 minutes
4 → 516 minutes
After 5 failed attemptsDelivery 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:

StatusMeaning
PendingQueued; the next job run will attempt it.
RetryingAn attempt failed; automatic backoff is in progress.
DeliveredYour endpoint answered 2xx.
DeadAll 5 attempts failed. Fix the endpoint, then use the Retry button to re-queue it manually.
How to check it works: trigger any subscribed event (or save the endpoint pointing at webhook.site first) and watch the delivery flip to Delivered in the log. A dead delivery with endpoint responded HTTP 500 means your receiver threw — check its own logs.
Clearing the endpoint URL stops all future events and marks anything still queued as dead. Changing topics only affects future events.

6. Receiver best practices

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.