Webhooks
Outgoing webhooks notify your system when something happens in the programme. Incoming webhooks are the notifications IncentIA receives from shops, payment gateways and evidence providers.
Outgoing
You create them in Integrations › Webhooks (permission Webhook Subscriptions, Pages.Loyalty.Webhooks). How to set them up is covered in Webhooks. From the screen, a subscription listens to one event; through the API it can listen to several.
Events
| Event | Sent when |
|---|---|
MemberCreated | A member registers |
PointsEarned | A member earns points |
PointsRedeemed | A member spends points |
TierChanged | A member changes tier |
RewardRedeemed | A member redeems a reward |
CouponUsed | A coupon is used |
PointsExpiring | A member's points are about to expire |
GamePrizeWon | A participant wins a prize in a game |
Lifecycle events
Besides the eight above, there are events for participation, games, raffles, coupons, members and payouts. They share the format, headers and signature of every other notification.
X-Event-Type | Value (eventTypes) | data.event | When it is sent |
|---|---|---|---|
EvidenceReceived | 9 | evidence.received | A receipt (claim) is received |
EvidenceValidated | 10 | evidence.validated | It is approved |
EvidenceRejected | 11 | evidence.rejected | It is rejected or is a duplicate |
EvidenceInReview | 12 | evidence.in_review | It waits for a person to review it |
EvidencePaid | 13 | evidence.paid | Its payout is settled |
ParticipationCreated | 14 | participation.created | A new participation is recorded |
ParticipationValidated | 15 | participation.validated | A participation becomes validated |
ParticipationRejected | 16 | participation.rejected | A participation becomes rejected |
GamePlayed | 17 | game.played | A play ends, with or without a prize |
GamePrizeWon | 8 | prize.won | A play awards a prize (also participants without a member account) |
RaffleDrawn | 18 | raffle.drawn | A raffle is drawn (one per raffle; for a per-store one, one per store) |
CouponIssued | 19 | coupon.issued | A coupon is created from the back office or a stamp card is completed. Not sent when issuing batches by API |
MemberUpdated | 20 | member.updated | A member's profile data changes |
MemberAnonymized | 21 | member.anonymized | A GDPR erasure completes |
PayoutCompleted | 22 | payout.completed | A cashback payout is completed or confirmed |
PayoutFailed | 23 | payout.failed | A payout fails for good (not sent while a retry is pending) |
JobCompleted | 24 | job.completed | A bulk job from /api/v1/jobs (import or export) finishes, successfully or not: check status |
X-Event-Type and the notification's event field carry the name in the first column; data.event carries the dotted name. data always starts with event, version (format version, currently 1) and occurredAt, followed only by identifiers, codes, statuses and amounts: never names, contact data or bank details. Adding fields does not raise version; removing or renaming them does.
{
"event": "ParticipationValidated",
"timestamp": "2026-11-01T10:15:02.0000000Z",
"data": {
"event": "participation.validated",
"version": 1,
"occurredAt": "2026-11-01T10:15:02Z",
"attemptId": 991, "campaignId": 17, "channel": "Landing", "trigger": "Evidence",
"status": "Validated", "outcome": null, "reason": null,
"participantId": 55, "memberId": null, "claimId": 1042
}
}
| Event | Main data fields |
|---|---|
participation.* | attemptId, campaignId, channel, trigger, status, outcome, participantId, memberId, claimId; reason only on rejected |
game.played | playId, gameId, memberId, participantId, result, prizeId, pointsSpent, pointsWon |
prize.won | playId, gameId, memberId, participantId, prizeId, prizeName, prizeType, pointsAwarded, couponCode |
raffle.drawn | drawId, campaignId, drawnAt, scope, storeId, slotKey, counts, outputHashSha256 and winners[] (winnerId, role, participantId, memberId, claimId) |
coupon.issued | couponId, code, source, loyaltyProgramId, campaignId, memberId, type, discountValue, maxUses, validFrom, validUntil |
member.updated | memberId, loyaltyProgramId, status, changedFields (field names only), source |
member.anonymized | memberId, loyaltyProgramId, certificateId |
payout.* | payoutId, memberId, campaignId, claimId, status, type, amount, currency, processedAt |
evidence.* | claimId, campaignId, externalParticipantRef, status, rejectionCode, rejectionReason, amount, currency |
All are emitted once the operation that causes them has been committed: if it is rolled back, nothing is sent. prize.won and coupon.issued carry the coupon code, because it is what your system has to deliver.
The Event Type selector in Integrations › Webhooks currently offers only the first eight. For the rest, create or edit the subscription with the administration API (/api/services/app/WebhookSubscription/Create or Update) and send the values in the table in eventTypes, for example "eventTypes": [14, 15, 16, 18]. A subscription can listen to several events.
You can also receive the events in your SQS queue or SNS topic instead of at a URL.
Notification format
IncentIA sends a POST with Content-Type: application/json:
{
"event": "PointsEarned",
"timestamp": "2026-10-22T09:30:00.0000000Z",
"data": { }
}
data depends on the event. The notification is emitted once the operation that triggers it has been committed.
| Header | Content |
|---|---|
X-Event-Type | Event name |
X-Timestamp | Notification date (ISO 8601) |
X-Correlation-Id | Tracking identifier |
X-IncentIA-Idempotency-Key | Unique key of the notification. It does not change across retries |
X-Signature | HMAC signature (only if the subscription has a Secret) |
Verify the signature
The signature is v1= followed by the lowercase hexadecimal HMAC-SHA256 of this text, keyed with your Secret:
{X-Timestamp}.{X-IncentIA-Idempotency-Key}.{unmodified body}
Use the X-Timestamp value exactly as received and the raw body, without re-serialising it.
Before computing anything, check the v1= prefix (see the versioning policy) and compare in constant time.
TypeScript
import crypto from 'node:crypto';
export function isValid(headers: Record<string, string | undefined>, rawBody: string, secret: string): boolean {
const signed = `${headers['x-timestamp']}.${headers['x-incentia-idempotency-key']}.${rawBody}`;
const expected = 'v1=' + crypto.createHmac('sha256', secret).update(signed).digest('hex');
const received = headers['x-signature'] ?? '';
return received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}
C#
using System.Security.Cryptography;
using System.Text;
static bool IsValid(string timestamp, string idempotencyKey, string rawBody, string received, string secret)
{
var signed = $"{timestamp}.{idempotencyKey}.{rawBody}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = "v1=" + Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(signed))).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(received ?? ""));
}
PHP
<?php
function isValid(array $headers, string $rawBody, string $secret): bool {
$signed = ($headers['X-Timestamp'] ?? '') . '.' . ($headers['X-IncentIA-Idempotency-Key'] ?? '') . '.' . $rawBody;
$expected = 'v1=' . hash_hmac('sha256', $signed, $secret);
return hash_equals($expected, $headers['X-Signature'] ?? '');
}
// $rawBody = file_get_contents('php://input'); // no json_decode
Because the signature covers the timestamp and the idempotency key, checking it also validates those headers. Store the keys you have processed to discard duplicates.
Delivery and retries
- Reply with
2xxwithin 30 seconds. - On failure, delivery is retried up to three more times.
408,425and429responses are retried too. - IncentIA does not follow redirects: a
3xxcounts as a failure. - The target URL must be public; internal addresses are rejected.
- The subscription disables itself after 10 consecutive failures, or 50 consecutive throttling responses (
408/425/429). The counter is shown under Consecutive Failures.
Reply 200 straight away and process the notification in the background, so you do not hit the timeout.
Redeliver a delivery
If your receiver was down, you can ask for a logged delivery to be sent again: in Integrations › Webhooks, Deliveries tab, Redeliver button, or by API with a key that has the webhooks:write scope:
curl -s -X POST "$BASE/api/v1/webhook-deliveries/4821/redeliver" \
-H "Authorization: Bearer $KEY"
# 202 {"deliveryId":4821,"subscriptionId":12,"eventType":"PointsEarned","idempotencyKey":"..."}
The same content and the same X-IncentIA-Idempotency-Key are sent again (your system must deduplicate), with a new X-Timestamp and signature, and only to the original URL. Refusals:
| Status | code | When |
|---|---|---|
404 | not_found | The delivery does not exist in your account |
409 | subscription_inactive | The subscription is deactivated |
409 | event_not_subscribed | The subscription no longer includes that event |
409 | target_changed | You changed the destination URL after the delivery |
409 | delivery_in_progress | A redelivery or retry is already in progress |
410 | subscription_gone | The subscription was deleted |
410 | payload_expired | The content is no longer kept (14 days by default) |
403 | feature_disabled | Webhooks are not included in the plan |
Incoming
These are public routes protected by a signature. IncentIA usually configures them when you connect the integration; this table is for debugging. The delivery log is under Administration › Incoming Webhooks.
| Source | Route | Signature |
|---|---|---|
| Shopify | POST /api/webhooks/shopify/{integrationId} | X-Shopify-Hmac-Sha256 (Base64 HMAC-SHA256 of the body) |
| WooCommerce | POST /api/webhooks/woocommerce/{integrationId} | X-WC-Webhook-Signature (Base64 HMAC-SHA256 of the body) |
| Stripe | POST /api/member/portal/payments/stripe-webhook | Stripe-Signature |
| Evidence (external validation) | POST /api/evidence/callback | X-Evidence-Signature |
For Shopify and WooCommerce the signing key is the integration's secret. Unsupported topics are ignored with 200 and {"status":"ignored"}.
Evidence callback
An external validation provider returns the verdict for a receipt. Sign the raw body with HMAC-SHA256, as hexadecimal or Base64, using the server's callback secret, and send it in X-Evidence-Signature.
{ "evidenceId": 123, "approved": true, "amount": 24.5, "reason": null, "timestamp": 1793750400 }
| Field | Notes |
|---|---|
evidenceId | Required |
approved | true approves, false rejects |
amount | Optional. Validated amount, up to 1,000,000 |
reason | Optional. Up to 500 characters |
timestamp | Required. Unix seconds when the body was signed; rejected if more than 5 minutes away from server time |
Responses: 200 with {"status":"processed"}, 401 if the signature is missing or does not match or timestamp is missing or outside the window, 422 if a person already rejected the evidence or marked it as a duplicate (an approved: true cannot overturn that verdict), 404 if the evidence does not exist, and 503 if the server has no secret configured. The body can be at most 2 MB.