Guide to Appstle Subscriptions Webhooks: Features and Best Practices
Appstle Subscriptions webhooks tell your systems when something changes in a Shopify store's subscriptions: a customer signs up, pauses, cancels or moves their next order date, or a renewal payment succeeds, fails or is skipped. If you run subscriptions on Appstle, webhooks are how those changes reach your CRM, fulfilment, analytics or dunning tools without polling Appstle's API.
This guide covers how Appstle webhooks are configured and signed, the two payload shapes behind its 11 events, the delivery behaviour Appstle documents, and the practices that keep an Appstle receiver reliable.
What are Appstle Subscriptions webhooks?
Appstle sends an HTTP POST with a JSON body to your endpoint whenever a subscribed event happens in your Appstle account. Appstle does not run its own delivery system. Its docs say "Appstle webhooks are powered by Svix", so the headers, the signing scheme, the retry schedule and the endpoint screens in the dashboard are all Svix's.
Every body has the same envelope, { "type": ..., "data": ... }. What goes in data depends on the event: seven events carry a full Shopify subscription contract, and four carry an Appstle billing attempt record.
Appstle webhook features
| Feature | Details |
|---|---|
| Configuration | Appstle dashboard, Settings > Webhooks |
| Subscription | Choose the events per endpoint |
| Delivery platform | Svix |
| Signature headers | svix-id, svix-timestamp, svix-signature |
| Scheme | Base64 HMAC-SHA256, sent as one or more v1,<signature> entries |
| Signed content | {svix-id}.{svix-timestamp}.{raw body} |
| Secret | One whsec_ signing secret per endpoint |
| Event type | type in the body |
| Success response | Any 2xx |
| Retries | Exponential backoff, 5 attempts across 3 days |
| Auto-disable | Endpoints that fail consistently are disabled |
| Testing | Send Example on the endpoint sends a sample event |
| Delivery logs | Settings > Webhooks > Message Logs |
Appstle says slow responses "will time out" but does not give the timeout. It publishes no source IP list, so the signature is what proves a request came from Appstle.
Common events
Appstle documents 11 event types.
| Event | Fires when | data |
|---|---|---|
subscription.created | A new subscription is created | Subscription contract |
subscription.updated | Subscription details are updated | Subscription contract |
subscription.activated | A subscription is activated | Subscription contract |
subscription.paused | A subscription is paused | Subscription contract |
subscription.cancelled | A subscription is cancelled | Subscription contract |
subscription.next-order-date-changed | The next order date is changed | Subscription contract |
subscription.billing-interval-changed | The billing frequency changes | Subscription contract |
subscription.billing-success | A payment is processed | Billing attempt |
subscription.billing-failure | A payment fails | Billing attempt |
subscription.billing-skipped | A billing cycle is skipped | Billing attempt |
subscription.upcoming-order-notification | An upcoming order reminder is sent | Billing attempt |
The contract events carry the Shopify subscription contract: id (a Shopify GraphQL ID such as gid://shopify/SubscriptionContract/12345), status (ACTIVE, PAUSED, CANCELLED, EXPIRED or FAILED), nextBillingDate, billingPolicy, deliveryPolicy, the line items under lines.nodes, customer, customerPaymentMethod and deliveryMethod.
The billing events carry Appstle's own record of the attempt. Here is Appstle's subscription.billing-failure example:
{
"type": "subscription.billing-failure",
"data": {
"id": 98766,
"shop": "example-store.myshopify.com",
"billingAttemptId": "gid://shopify/SubscriptionBillingAttempt/99998",
"contractId": 12345,
"status": "FAILURE",
"billingDate": "2026-03-15T00:00:00Z",
"attemptTime": "2026-03-15T10:30:00Z",
"attemptCount": 1,
"graphOrderId": null,
"orderId": null,
"retryingNeeded": true,
"transactionFailedEmailSentStatus": "SENT",
"billingAttemptResponseMessage": "INVALID_PAYMENT_METHOD: The payment method is invalid. Please update your payment information."
}
}
status is SUCCESS, FAILURE, SKIPPED or PENDING. billingAttemptResponseMessage starts with a code such as INSUFFICIENT_FUNDS, CARD_DECLINED, AUTHENTICATION_REQUIRED or INVENTORY_, and retryingNeeded tells you whether Appstle has scheduled another attempt.
Note that the two shapes identify the subscription differently. A contract event has data.id as a gid:// string, while a billing event has data.contractId as a bare number, 12345. To join them, take the number from the end of the gid:// ID.
Setting up Appstle webhooks
- In the Appstle dashboard, go to Settings > Webhooks.
- Add your endpoint URL and select the events you want to receive.
- Open the endpoint and copy its signing secret, which starts with
whsec_. Store it server-side, for example asAPPSTLE_WEBHOOK_SECRET. - Use Send Example on the endpoint to send a sample event and check that your handler accepts it.
If you receive Appstle webhooks through Hookdeck, create a source with the Appstle Subscriptions type, paste the whole whsec_ secret, and use the source URL as the endpoint in Appstle.
Securing Appstle webhooks
Appstle's deliveries are signed the way Svix signs every webhook. Strip whsec_ from the secret and base64-decode the rest to get the key bytes. Concatenate the svix-id header, a period, the svix-timestamp header, a period and the raw body. Compute an HMAC-SHA256, base64-encode it, and compare it in constant time with each v1, entry in svix-signature. There is more than one entry while a secret is being rotated, and any match passes.
const crypto = require('crypto');
const express = require('express');
function verifyAppstleSignature(rawBody, headers, secret) {
const id = headers['svix-id'];
const timestamp = headers['svix-timestamp'];
const signatureHeader = headers['svix-signature'];
if (!id || !timestamp || !signatureHeader) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = crypto
.createHmac('sha256', key)
.update(Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]))
.digest();
return signatureHeader.split(' ').some((entry) => {
const [version, value] = entry.split(',');
if (version !== 'v1' || !value) return false;
const received = Buffer.from(value, 'base64');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
const app = express();
app.post('/webhooks/appstle', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.APPSTLE_WEBHOOK_SECRET;
if (!secret || !secret.startsWith('whsec_')) return res.sendStatus(500); // fail closed
if (!verifyAppstleSignature(req.body, req.headers, secret)) return res.sendStatus(400);
const event = JSON.parse(req.body.toString('utf8'));
// Deduplicate on the svix-id header, then route on event.type.
res.sendStatus(200);
});
The same check in Python:
import base64
import hashlib
import hmac
def verify_appstle_signature(raw_body: bytes, headers, secret: str) -> bool:
msg_id = headers.get("svix-id")
timestamp = headers.get("svix-timestamp")
signature_header = headers.get("svix-signature")
if not (msg_id and timestamp and signature_header):
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed_content = f"{msg_id}.{timestamp}.".encode("utf-8") + raw_body
expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest())
for entry in signature_header.split(" "):
version, _, value = entry.partition(",")
if version == "v1" and hmac.compare_digest(value.encode("utf-8"), expected):
return True
return False
# In FastAPI: raw_body = await request.body()
# verify_appstle_signature(raw_body, request.headers, os.environ["APPSTLE_WEBHOOK_SECRET"])
Appstle's docs use the official svix library instead (npm i svix, pip install svix). It does the same work, and it also rejects any request whose svix-timestamp is more than 5 minutes from your server's clock, with no option to change that. Hookdeck retries and replays resend the original svix-timestamp, so an event retried or replayed through Hookdeck more than 5 minutes after Appstle sent it fails the library's check even though the signature is valid. The hand-written verifier above has no window. The Appstle webhook skill has a manual verifier that takes an optional tolerance if you want one.
Make Appstle webhooks production-ready. Hookdeck Event Gateway verifies the Svix signature on every Appstle delivery and durably queues each subscription and billing event before your service sees it.
Appstle webhook limitations and pain points
The official sample verifies the wrong bytes
The Problem: Verification fails on some or all deliveries, even with the right secret.
Why It Happens: Appstle's Node sample passes JSON.stringify(req.body) to the verifier. That re-serialises JSON your framework has already parsed, and the result can differ from the bytes Svix signed in whitespace, key order or number formatting. Appstle's own troubleshooting table says not to modify the raw body before verification.
Workarounds:
- Mount
express.raw({ type: '*/*' })on the webhook route and verifyreq.bodyas a Buffer. - Parse the JSON only after verification passes.
- Exempt the route from CSRF middleware, which Appstle also calls out.
How Hookdeck Can Help: Hookdeck's Appstle Subscriptions source verifies the signature against the bytes Appstle sent, before any parsing. Hookdeck answers Appstle with a 200 either way, then records a request that fails the check as failed verification, so it never reaches your destination.
Late retries and replays fail the library's timestamp check
The Problem: Fresh events verify, but retries or replays of older events are rejected.
Why It Happens: The svix library always enforces a 5-minute window on svix-timestamp. A request that reaches your code more than 5 minutes after it was signed, because it waited in a queue or was retried or replayed by a gateway that keeps the original headers, is rejected as too old.
Workarounds:
- Verify manually, as shown above, and decide for yourself whether to check the timestamp.
- Deduplicate on
svix-id, so accepting a late delivery cannot cause double processing.
How Hookdeck Can Help: Hookdeck applies no timestamp window when it verifies, so a retry runs the same check and gets the same answer.
Two shapes and two ID formats
The Problem: Code that reads data.id as the subscription ID works for contract events and silently breaks for billing events.
Why It Happens: Contract events carry the Shopify contract, where id is a gid://shopify/SubscriptionContract/... string. Billing events carry Appstle's attempt record, where id is the attempt's own number and the subscription is contractId, a bare number.
Workarounds:
- Route on
typefirst, then read the ID field that belongs to that shape. - Normalise both to the numeric contract ID before storing anything.
How Hookdeck Can Help: Hookdeck filters on type can send contract events and billing events to different handlers, and a transformation can add a normalised contract ID before delivery.
An outage longer than three days loses events
The Problem: After a long outage, some events never arrive, and the endpoint may have been switched off.
Why It Happens: Svix retries 5 times across 3 days and then stops, and it disables endpoints that fail consistently. Appstle doesn't publish the exact disable threshold.
Workarounds:
- Return
2xxstraight after verification and process asynchronously. - Check Message Logs after any incident and retry failed messages from there.
- Reconcile subscriptions against the Appstle API after a long outage.
How Hookdeck Can Help: Hookdeck accepts each delivery as soon as it arrives, so Appstle's retries are not used up while your service is down, and then delivers to your service with retries you configure.
Best practices
Verify against the raw body
Svix signs the exact bytes it sends. In Express, use express.raw({ type: '*/*' }). In Next.js, use await request.text(). In FastAPI, use await request.body().
Deduplicate on svix-id
Appstle says webhooks "may be delivered more than once" and tells you to use svix-id for idempotency. It stays the same across retries of one message. Store it before processing and skip any you have already handled. See our guide to webhook idempotency.
Treat billing failures as the start of dunning
subscription.billing-failure with retryingNeeded: true means Appstle will try again. Use the code at the start of billingAttemptResponseMessage to decide what to tell the customer: an expired card needs a payment update, while INVENTORY_ is a stock problem, not a payment one.
Fetch details you don't get
Six of the seven contract events have no example of their own in Appstle's docs. Appstle says they carry the same contract shape, but treat optional fields such as discounts, note and customAttributes as possibly absent, and read the contract from the API when you need a field that isn't there.
Acknowledge fast and process asynchronously
Appstle tells you to return 200 OK immediately and do the work in a background job, and to return 2xx even when your own business logic fails. See why to process webhooks asynchronously.
Conclusion
Appstle hands delivery to Svix, so every request is signed with a base64 HMAC-SHA256 over the message ID, timestamp and raw body, keyed with the decoded whsec_ secret for that endpoint. Verify the raw bytes rather than re-serialised JSON, route on type before you read any ID, and deduplicate on svix-id.
Hookdeck Event Gateway sits in front of your receiver, verifies every Appstle delivery, and holds each event until your service has processed it.
Get started with Hookdeck for free and handle Appstle webhooks reliably in minutes.