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.
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
httpson the default port (443), with a full domain name; - not an IP address, not
localhostand 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:truefor the live environment.GiftingStack-Delivery-Id: one delivery attempt chain to one endpoint.
Event types
type | When | data |
|---|---|---|
gift.sent | A 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.reversed | A 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.credited | Coins 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.debited | Coins were debited. | The ledger entry, with type debit and a negative amount. |
user.deleted | A 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.refunded | An 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.
- Take the raw body bytes. Do not parse and re-serialise the JSON first: the signature covers the exact bytes we sent.
- Reject the request if
tis more than 300 seconds from your clock. This stops replays of captured requests. - Compute the HMAC and compare it to each
v1with 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
2xxwithin 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
3xxcounts 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_letteredin the dashboard's delivery list. It is not lost: you can send it again, or catch up withGET /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
idagain, 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
idof 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
sequenceif 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.