Docs
Send your first event in about a minute.
If the work happens in GitHub, install the app and skip this page. For everything else: post a signed JSON body and call the event whatever your business calls it.
1. The endpoint
One URL, two headers
Your organization id and its signing secret are both on the Connect screen inside the app. The secret is per organization: rotating it takes effect immediately and the old one stops working.
| Header | Value |
|---|---|
x-gamify-timestamp | Unix seconds, as an integer |
x-gamify-signature | v0= then the hex HMAC below |
content-type | application/json |
# The timestamp is signed WITH the
# body, so a captured request cannot
# be moved forward in time.
base = "v0:" + timestamp + ":" + rawBody
sig = "v0=" + hex(
HMAC_SHA256(secret, base))
# Sign the bytes you send.
# Re-serializing parsed JSON reorders
# keys and breaks every signature.
2. Send it
Copy this, change three values
Nothing here needs a client library. It is a POST with an HMAC, and every language you might use already has both.
ORG="org_..."
SECRET="whsec_..."
TS=$(date +%s)
BODY='{"eventType":"deal_won","dedupeKey":"crm:deal:4417:won","externalId":"[email protected]","amount":40000}'
SIG="v0=$(printf "v0:%s:%s" "$TS" "$BODY" \
| openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"
curl -sS -X POST "https://gamifywork.ai/api/connectors/webhook/$ORG" \
-H "content-type: application/json" \
-H "x-gamify-timestamp: $TS" \
-H "x-gamify-signature: $SIG" \
--data-raw "$BODY"
# {"ok":true,"queued":true}
import { createHmac } from 'node:crypto';
const body = JSON.stringify({
eventType: 'deal_won',
dedupeKey: 'crm:deal:4417:won',
externalId: '[email protected]',
amount: 40000,
});
const ts = Math.floor(Date.now() / 1000);
const sig = `v0=${createHmac(
'sha256', secret).update(`v0:${ts}:${body}`).digest('hex')}`;
await fetch(`${base}/api/connectors/webhook/${orgId}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-gamify-timestamp': String(ts),
'x-gamify-signature': sig,
},
body, // the same string you signed
});
3. The body
Two fields are required. The rest is yours.
| Field | Required | What it does |
|---|---|---|
eventType | Yes | Becomes the metric key. Call it what your business calls it, such as deal_won, filing_accepted, or ticket_resolved, and rank a board on it the same afternoon. |
dedupeKey | Yes | Identical for two deliveries of the same real act, different for two different acts. This is what makes retrying safe. |
externalId | No | Who did it. Matched against a linked identity; without it the event still counts for the org but is credited to nobody. |
provider | No | Which identity namespace externalId belongs to. Defaults to email. |
occurredAt | No | When it happened. Defaults to now; set it when you are backfilling, or the whole backfill lands on today. |
durationH | No | Hours, as a number. Kept as a raw sample so percentiles are computed properly rather than averaged. |
| anything else | No | Kept on the event and readable by your rules, so a bonus can key off amount or region without us knowing what those are. |
A missing field answers 202 and does nothing
If eventType or dedupeKey is absent, the delivery is authenticated, accepted and then discarded. You get {"ok":true,"queued":true} and no event, because the check happens after the queue rather than at the endpoint.
It is the first thing to suspect when a board stays empty and every request looks successful. Send one event, then look for it on a board or in the feed before you write the loop that sends ten thousand.
4. What you get back
Three answers, and what each means
| Status | Body | Meaning |
|---|---|---|
202 | {"ok":true,"queued":true} | Signature verified and the delivery is queued. This is not a promise that it became an event; see the warning above. |
400 | {"ok":false,"error":"invalid_json"} | Authenticated, and the body is not JSON. Safe to say plainly, because you already proved you hold the secret. |
401 | {"ok":false} | Bad signature, stale timestamp, or an organization we do not recognise. Deliberately the same answer: distinguishing them would let a stranger enumerate our customers. |
Timestamps more than five minutes from our clock are rejected, in both directions. A forged future timestamp would otherwise buy an arbitrarily long replay window.
5. Then what
It is a metric the moment it lands.
The event is deduplicated and written, rollups are updated in the same transaction, and your rules run against it. Nothing has to be configured first for the number to exist. You can send an event now and build the board that ranks it afterwards.
- Your
eventTypebecomes a metric key you can rank on, with no migration - Every event also feeds a generic
activitymetric, so the source-agnostic boards work immediately - A rule can award a card, and a modifier can raise or lower the odds using any number you sent
- Retry as often as you like: the deduplication key, not the transport, is what makes it safe
Choosing a dedupe key
Name the act, not the request.
A good key is derivable from the thing that happened, so a backfill and a live delivery produce the same one. Ours for GitHub is github:<event>:<owner>/<repo>#<pr>:<login>, with no timestamps, no request ids, and nothing that changes on a replay.
crm:deal:4417:won, naming the deal and what happened to itsupport:ticket:8812:resolved:2, with a counter, because a ticket can genuinely be resolved twice
- A UUID generated per request, so every retry counts again
- A hash of the body, where one reordered key produces a different event
Troubleshooting
If it is not working
Every request returns 401.
Three usual causes, in order of likelihood. You signed a re-serialized body rather than the exact bytes you sent. Build the string once and use it for both. Your clock is more than five minutes out. Or the organization id in the URL is wrong, which answers 401 rather than 404 on purpose.
I get 202 but nothing appears anywhere.
Almost always a missing eventType or dedupeKey. Check the exact JSON you sent rather than the object you meant to send.
The event is there but credited to nobody.
The externalId did not match a linked identity. Link it on the identities screen and the work it already sent is re-attributed, so you do not need to resend anything.
I sent the same event twice and it counted twice.
The two deliveries had different dedupeKey values. If the key contains a timestamp, a request id or a hash of the body, it is not stable across a retry.
Can I backfill history?
Yes. Set occurredAt on each event, and keep the dedupe keys identical to the ones your live path sends so the two cannot double-count. Without occurredAt the entire backfill lands on today and every board reads as one enormous day.
Send one event and watch it land.
Free for your first 50 people. No card, no sales call.