Gareth Wilson Gareth Wilson

Guide to Appstle Subscriptions Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationAppstle dashboard, Settings > Webhooks
SubscriptionChoose the events per endpoint
Delivery platformSvix
Signature headerssvix-id, svix-timestamp, svix-signature
SchemeBase64 HMAC-SHA256, sent as one or more v1,<signature> entries
Signed content{svix-id}.{svix-timestamp}.{raw body}
SecretOne whsec_ signing secret per endpoint
Event typetype in the body
Success responseAny 2xx
RetriesExponential backoff, 5 attempts across 3 days
Auto-disableEndpoints that fail consistently are disabled
TestingSend Example on the endpoint sends a sample event
Delivery logsSettings > 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.

EventFires whendata
subscription.createdA new subscription is createdSubscription contract
subscription.updatedSubscription details are updatedSubscription contract
subscription.activatedA subscription is activatedSubscription contract
subscription.pausedA subscription is pausedSubscription contract
subscription.cancelledA subscription is cancelledSubscription contract
subscription.next-order-date-changedThe next order date is changedSubscription contract
subscription.billing-interval-changedThe billing frequency changesSubscription contract
subscription.billing-successA payment is processedBilling attempt
subscription.billing-failureA payment failsBilling attempt
subscription.billing-skippedA billing cycle is skippedBilling attempt
subscription.upcoming-order-notificationAn upcoming order reminder is sentBilling 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_ALLOCATIONS_NOT_FOUND, 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

  1. In the Appstle dashboard, go to Settings > Webhooks.
  2. Add your endpoint URL and select the events you want to receive.
  3. Open the endpoint and copy its signing secret, which starts with whsec_. Store it server-side, for example as APPSTLE_WEBHOOK_SECRET.
  4. 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 verify req.body as 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 type first, 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 2xx straight 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_ALLOCATIONS_NOT_FOUND 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.