Skip to content

Webhooks

Charm can push loyalty events to your server the moment they happen, so an ERP, CDP or a no-code tool like Make or Zapier reacts in seconds instead of polling /customers on a schedule.

TopicFires when
points.earnedA customer earns points (orders, rules, API awards)
points.adjustedA balance is corrected or deducted — points is signed, negative for deductions
reward.redeemedPoints are spent on a reward
tier.changedA customer reaches a new VIP tier
tier.approachingA customer gets close to the next tier
store_credit.issuedStore credit lands on a customer’s account
referral.completedA referred friend’s qualifying order completes

Webhooks are managed over the API with a key that carries the webhooks:manage scope:

POST /api/v1/webhooks
{
"url": "https://example.com/charm-hook",
"topics": ["points.earned", "tier.changed"]
}

The response includes the endpoint’s signing secret exactly once — store it; it is never shown again (lose it and you delete + recreate the endpoint, which costs nothing). GET /webhooks lists your endpoints with delivery health, DELETE /webhooks/{id} stops deliveries. A store can hold up to 10 endpoints, and the URL must be public https.

POST https://example.com/charm-hook
Content-Type: application/json
X-Charm-Topic: points.earned
X-Charm-Event-Id: evt_kFb2p9qArM3w
X-Charm-Hmac-Sha256: q1nZ…=
Charm-Version: 2026-10-01
{
"id": "evt_kFb2p9qArM3w",
"topic": "points.earned",
"created_at": "2026-08-20T13:00:00.000Z",
"shop": "your-store.myshopify.com",
"payload": {
"customer_id": "gid://shopify/Customer/7712345",
"customer_legacy_id": 7712345,
"points": 120,
"balance_after": 340,
"source": "order"
}
}

payload.customer_id is the Shopify customer GID, the same id the customer endpoints return and accept; payload.customer_legacy_id is the numeric id. Every topic’s payload, field by field: Webhook events.

Charm-Version is the API version the endpoint was created with; the body is always rendered in it, so a receiver never sees a breaking change it did not opt into.

X-Charm-Hmac-Sha256 is the base64 HMAC-SHA256 of the raw request body, keyed with the endpoint’s secret. Verify before trusting anything:

import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret) {
const expected = createHmac("sha256", secret).update(rawBody, "utf8").digest("base64");
const a = Buffer.from(expected);
const b = Buffer.from(String(header || ""));
return a.length === b.length && timingSafeEqual(a, b);
}

Compute it over the raw bytes — re-serializing the parsed JSON will produce a different string and a false mismatch.

  • Acknowledge fast. Respond with any 2xx within 5 seconds. Do the real work after responding if it might take longer.
  • At-least-once. A delivery that isn’t acknowledged is retried with backoff (1 min → 24 h, 7 attempts over ~33 hours), then dropped. Duplicates are possible — dedupe on the event id, which stays the same across every retry.
  • Redirects are not followed. Register the final URL.
  • Failing endpoints switch off. After 25 consecutive failed attempts the endpoint is disabled (GET /webhooks shows disabled_reason). Turn it back on in the developer console once your receiver is healthy, or delete and recreate it.

Settings → API → Developer console shows each endpoint’s last 20 deliveries with their attempts and the response your server gave.

  • Send test event delivers a sample of a subscribed topic. Its event id starts with evt_test_ and its payload carries "test": true, with a fictional customer: skip such events in production logic.
  • Send again re-sends a past delivery with the same event id. A receiver that already processed it deduplicates; one that failed gets another chance.