Skip to main content

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​

EventSent when
MemberCreatedA member registers
PointsEarnedA member earns points
PointsRedeemedA member spends points
TierChangedA member changes tier
RewardRedeemedA member redeems a reward
CouponUsedA coupon is used
PointsExpiringA member's points are about to expire
GamePrizeWonA 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-TypeValue (eventTypes)data.eventWhen it is sent
EvidenceReceived9evidence.receivedA receipt (claim) is received
EvidenceValidated10evidence.validatedIt is approved
EvidenceRejected11evidence.rejectedIt is rejected or is a duplicate
EvidenceInReview12evidence.in_reviewIt waits for a person to review it
EvidencePaid13evidence.paidIts payout is settled
ParticipationCreated14participation.createdA new participation is recorded
ParticipationValidated15participation.validatedA participation becomes validated
ParticipationRejected16participation.rejectedA participation becomes rejected
GamePlayed17game.playedA play ends, with or without a prize
GamePrizeWon8prize.wonA play awards a prize (also participants without a member account)
RaffleDrawn18raffle.drawnA raffle is drawn (one per raffle; for a per-store one, one per store)
CouponIssued19coupon.issuedA coupon is created from the back office or a stamp card is completed. Not sent when issuing batches by API
MemberUpdated20member.updatedA member's profile data changes
MemberAnonymized21member.anonymizedA GDPR erasure completes
PayoutCompleted22payout.completedA cashback payout is completed or confirmed
PayoutFailed23payout.failedA payout fails for good (not sent while a retry is pending)
JobCompleted24job.completedA 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
}
}
EventMain data fields
participation.*attemptId, campaignId, channel, trigger, status, outcome, participantId, memberId, claimId; reason only on rejected
game.playedplayId, gameId, memberId, participantId, result, prizeId, pointsSpent, pointsWon
prize.wonplayId, gameId, memberId, participantId, prizeId, prizeName, prizeType, pointsAwarded, couponCode
raffle.drawndrawId, campaignId, drawnAt, scope, storeId, slotKey, counts, outputHashSha256 and winners[] (winnerId, role, participantId, memberId, claimId)
coupon.issuedcouponId, code, source, loyaltyProgramId, campaignId, memberId, type, discountValue, maxUses, validFrom, validUntil
member.updatedmemberId, loyaltyProgramId, status, changedFields (field names only), source
member.anonymizedmemberId, 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.

Subscribing to these events

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.

HeaderContent
X-Event-TypeEvent name
X-TimestampNotification date (ISO 8601)
X-Correlation-IdTracking identifier
X-IncentIA-Idempotency-KeyUnique key of the notification. It does not change across retries
X-SignatureHMAC 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 2xx within 30 seconds.
  • On failure, delivery is retried up to three more times. 408, 425 and 429 responses are retried too.
  • IncentIA does not follow redirects: a 3xx counts 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.
tip

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:

StatuscodeWhen
404not_foundThe delivery does not exist in your account
409subscription_inactiveThe subscription is deactivated
409event_not_subscribedThe subscription no longer includes that event
409target_changedYou changed the destination URL after the delivery
409delivery_in_progressA redelivery or retry is already in progress
410subscription_goneThe subscription was deleted
410payload_expiredThe content is no longer kept (14 days by default)
403feature_disabledWebhooks 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.

SourceRouteSignature
ShopifyPOST /api/webhooks/shopify/{integrationId}X-Shopify-Hmac-Sha256 (Base64 HMAC-SHA256 of the body)
WooCommercePOST /api/webhooks/woocommerce/{integrationId}X-WC-Webhook-Signature (Base64 HMAC-SHA256 of the body)
StripePOST /api/member/portal/payments/stripe-webhookStripe-Signature
Evidence (external validation)POST /api/evidence/callbackX-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 }
FieldNotes
evidenceIdRequired
approvedtrue approves, false rejects
amountOptional. Validated amount, up to 1,000,000
reasonOptional. Up to 500 characters
timestampRequired. 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.