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…=
{
"id": "evt_kFb2p9qArM3w",
"topic": "points.earned",
"created_at": "2026-08-20T13:00:00.000Z",
"shop": "your-store.myshopify.com",
"payload": {
"customer_id": "7712345",
"points": 120,
"balance_after": 340,
"source": "order"
}
}

payload.customer_id is the numeric Shopify customer id — the same value the customer endpoints accept.

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); delete and recreate it once your receiver is healthy.