GiftingStack
Sign in Get started
Docs menu

Docs

Webhooks

GiftingStack calls your server when something happens: a gift is sent or reversed, coins are credited or debited, a user is deleted, a store reports a refund.

Checked against the API and the live OpenAPI on 2026-10-21.

Set up an endpoint

Endpoints are managed in the dashboard (project, Webhooks); there is no public API for them. Each endpoint belongs to one environment (live or test), takes events from that environment only, and can be limited to the event types you choose. A project can have 5 endpoints per environment. The signing secret (whsec_…) is shown once when you create the endpoint.

The URL must be:

  • public https on the default port (443), with a full domain name;
  • not an IP address, not localhost and not a private or internal name. We also refuse a name that resolves to a private address, at delivery time.

In the dashboard you can send a test event (type webhook.test), see the latest deliveries with their status, and send a delivery again.

What you receive

A POST with a JSON body, this envelope, and these headers:

POST /your/webhook HTTP/1.1
Content-Type: application/json
User-Agent: GiftingStack-Webhooks/1
GiftingStack-Signature: t=1760963400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
GiftingStack-Event-Id: evt_01JA0Q8X2M4N6P8R0T2V4W6Y8Z
GiftingStack-Delivery-Id: whd_01JA0Q8X2M4N6P8R0T2V4W6Y8Z

{
  "id": "evt_01JA0Q8X2M4N6P8R0T2V4W6Y8Z",
  "sequence": 1843,
  "type": "gift.sent",
  "created_at": "2026-10-20T12:00:00.000Z",
  "livemode": false,
  "data": { … }
}
  • id: unique per event. The same event always has the same id, even if it is delivered more than once.
  • sequence: strictly increasing per project and environment. Use it to order events and to catch up.
  • livemode: true for the live environment.
  • GiftingStack-Delivery-Id: one delivery attempt chain to one endpoint.

Event types

typeWhendata
gift.sentA gift was sent.The gift transaction (GiftTransaction in the OpenAPI): id (gtx_…), sender, receiver, gift (id, name, icon_url), quantity, unit_price, total, message, reference, metadata, status completed, reversal null, created_at.
gift.reversedA gift was reversed and the sender refunded.The same gift transaction, now with status reversed and reversal filled in (id grv_…, reason, reference, metadata, created_at).
balance.creditedCoins were credited (POST /v1/users/{id}/credit).The ledger entry (LedgerEntry): id (led_…), user, type credit, amount, balance_after, reason, reference, metadata, price, currency, price_source, receipt_id, created_at.
balance.debitedCoins were debited.The ledger entry, with type debit and a negative amount.
user.deletedA user was deleted.id (the GiftingStack id), external_id (the deleted_… pseudonym, never your original id), deleted true, forfeited_balance, forfeit_entry_id, deleted_at.
purchase.refundedAn App Store or Google Play notification reported a refund (only if you set up store notifications).platform, transaction_id, original_transaction_id, product_id, price, currency, refunded_at, receipt_id, credit_entry_id. Information only: nothing in the ledger changes. See Receipt validation.

Spending coins on a gift, refunds from a reversal and the forfeit at user deletion do not send a balance.* event of their own: the gift.* and user.deleted events describe them. The ids of the gift.* events reuse the id of the row they describe (gtx_01J… becomes evt_01J…; for gift.reversed it is the reversal grv_01J…); user.deleted gets an id of its own.

message and metadata are your users' content. Keep them out of your logs, as we do.

Verify the signature

Every request is signed so you know it came from GiftingStack. The header is t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 over the string <t>.<raw request body>, using your endpoint's secret (the whole whsec_… string) as the key. The header can hold more than one v1 value (see rotation below): accept the request when any of them matches.

  1. Take the raw body bytes. Do not parse and re-serialise the JSON first: the signature covers the exact bytes we sent.
  2. Reject the request if t is more than 300 seconds from your clock. This stops replays of captured requests.
  3. Compute the HMAC and compare it to each v1 with a constant-time comparison.

Node (no dependencies), with an Express receiver:

import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

export function verify(rawBody, header, secret, toleranceSeconds = 300) {
  const fields = header.split(",").map((p) => p.trim().split("="));
  const t = Number(fields.find(([k]) => k === "t")?.[1]);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return fields.some(
    ([k, v]) =>
      k === "v1" &&
      v !== undefined &&
      v.length === expected.length &&
      timingSafeEqual(Buffer.from(v), Buffer.from(expected)),
  );
}

const app = express();
// the raw body, as a string: do not use express.json() on this route
app.post("/your/webhook", express.text({ type: "application/json" }), (req, res) => {
  if (!verify(req.body, req.get("GiftingStack-Signature") ?? "", process.env.GS_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body);
  // 1. skip it if you already handled event.id   2. do the work   3. answer fast
  res.sendStatus(200);
});
app.listen(3000);

Python:

import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    fields = [p.strip().split("=", 1) for p in header.split(",") if "=" in p]
    try:
        t = int(next(v for k, v in fields if k == "t"))
    except (StopIteration, ValueError):
        return False
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(k == "v1" and hmac.compare_digest(v, expected) for k, v in fields)

Test your verifier without any tool: this Node script signs a sample body the same way we do and checks it with the function above.

import { createHmac } from "node:crypto";
import { verify } from "./verify.js";

const secret = "whsec_" + "x".repeat(32); // any string works for this local test
const body = JSON.stringify({ id: "evt_test", sequence: 1, type: "gift.sent", livemode: false, data: {} });
const t = Math.floor(Date.now() / 1000);
const header = `t=${t},v1=${createHmac("sha256", secret).update(`${t}.${body}`).digest("hex")}`;

console.log(verify(body, header, secret)); // true
console.log(verify(body + " ", header, secret)); // false: the body changed

Answer, retries and delivery rules

  • Answer with any 2xx within 10 seconds. Do the slow work after you answer.
  • Anything else is a failure: another status, a timeout, a connection error. Redirects are not followed, so a 3xx counts as a failure: give us the final URL.
  • A failed delivery is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours, and then every 12 hours, for up to 5 days from the first attempt. After that it is marked failed.
  • An endpoint that fails continuously for 5 days is switched off in the dashboard. Enable it again after you fix it.
  • If our own queue gives up on a delivery (rare, an infrastructure failure on our side), it is marked failed with the error dead_lettered in the dashboard's delivery list. It is not lost: you can send it again, or catch up with GET /v1/events.
  • The delivery list shows the most recent deliveries with status, attempts and the last error, and keeps them for 90 days. Sending one again needs its event, which is kept for 30 days.
  • Events go through a durable outbox: a ledger change and its event are saved together, and the event is sent afterwards, never from the request that caused it. So an event is never lost, but it may arrive a moment after the API answer. Use the API response to update your UI and webhooks for background work.

Send a delivery again

In the dashboard, open a delivery and choose Send again. The rules:

  • The endpoint must be enabled. Test events (webhook.test) cannot be sent again: send a new one.
  • A delivery that is still being retried cannot be sent again (it is in progress). One that is still marked pending after the 5-day window is treated as finished and can be sent again.
  • The event is read from the stored copy, so it carries the redacted payload if its user was deleted since. An event older than 30 days is gone and cannot be sent.
  • A resend starts a fresh set of attempts with the same event id and the retry schedule above. Your handler sees the same id again, so keep ignoring repeats.

At least once, not in order

  • Delivery is at least once. The same event can arrive twice (a retry after a slow answer, or a send again from the dashboard). Store the id of handled events and ignore repeats, or make the work itself idempotent.
  • Order is not guaranteed. A retried event can arrive after a newer one. Use sequence if order matters, and never assume that you saw every number in between.
  • Replay safety. A captured request replayed by an attacker fails the 5-minute timestamp check; an old valid delivery repeated by us has an id you have already seen.

Catch up on missed events

If your server was down longer than the retry window, or you want to check you missed nothing, list events directly. They are kept 30 days, oldest first, up to 100 per page, and the payloads of deleted users are redacted:

curl "https://api.giftingstack.com/v1/events?limit=100" \
  -H "Authorization: Bearer $GS_KEY"

# {"data":[{"id":"evt_…","sequence":1844,"type":"gift.sent", …}],
#  "has_more":true,"next_cursor":"ev1.WzE4NDRd"}

curl "https://api.giftingstack.com/v1/events?cursor=ev1.WzE4NDRd&limit=100" \
  -H "Authorization: Bearer $GS_KEY"

Every page carries a next_cursor. Pass it as cursor to get the events after that page, and keep going until has_more is false. Store the last next_cursor: the next time you catch up, start from it and you get only what is new. The cursor is opaque: do not build or change it. Without a cursor the list starts at the oldest kept event.

You can also pass the last sequence you handled as after_sequence instead of a cursor. That works while your project's events have one running number. A high-volume project (one with a sharded ledger) numbers its events per shard: there, sequence orders the events of one user, not of the whole project, and the list pages by cursor only (after_sequence answers 400). Paging by cursor works on every project, so prefer it.

Rotating the secret

Rotate in the dashboard. For 24 hours both the new and the old secret sign each request, so the header carries two v1 values. Deploy the new secret within that time; a verifier like the ones above accepts either. Once your server has the new secret, or if the old one leaked, choose Revoke the old secret now next to the endpoint: from then on only the new secret signs.

Testing on your machine

An endpoint must be public https on port 443, so http://localhost:3000 is refused. Run a tunnel (for example Cloudflare Tunnel or ngrok) that gives your local server a public https name, create a test-environment endpoint with it, and use Send test event in the dashboard. Then trigger a real event with a test key, for example a credit.

Questions or something unclear? support@giftingstack.com.