Skip to main content

Webhooks

Selgeo sends real-time HTTP POST notifications to your server when events occur in your account. Use webhooks to automate workflows such as provisioning access when a partner is approved, syncing commissions to your accounting system, or alerting your team about fraud.

All webhook payloads use API version v1.

Endpoint registration

Register webhook endpoints from the Settings > Webhooks page in the merchant dashboard.

  1. Click Add endpoint.
  2. Enter the URL where you want to receive events. All endpoints — test and live — must use HTTPS and resolve to a publicly reachable host. To receive test-mode webhooks during local development, expose your local server through a tunnel such as ngrok and register the tunnel's HTTPS URL.
  3. Select the events you want to subscribe to.
  4. Click Create. Your signing secret (whsec_...) is displayed once — copy it and store it securely.

You can register up to 10 endpoints per mode (test and live separately).

Test pings

After creating an endpoint, use the Send test ping button to verify connectivity. The ping sends a webhook.test event to your URL and reports the HTTP status code.

Rotating secrets

If your signing secret is compromised, use the Rotate secret action on the endpoint. This generates a new secret immediately — the old one stops working. Update your verification code before rotating.

Signature verification

Every webhook request includes an X-Selgeo-Signature header with the format:

t=<unix_timestamp>,v1=<hmac_hex>

The HMAC is computed as HMAC-SHA256(signing_secret_bytes, "<timestamp>.<raw_json_body>"). The signing secret has a whsec_ prefix followed by 64 hex characters. Strip the prefix and hex-decode to get the 32-byte key.

Verify BOTH the signature AND the timestamp

Your endpoint MUST reject requests that fail either check:

  1. HMAC signature must match the header's v1=<hmac> value (use a constant-time comparison).
  2. Timestamp must be recent — reject if now - t > 300 seconds or t - now > 30 seconds.

The signature alone proves the payload came from Selgeo, but it does not prove the request is fresh. Without the timestamp check, anyone who intercepts a single valid webhook (via a misconfigured proxy, log file, or any other capture point between Selgeo and your server) can replay it indefinitely — the HMAC stays valid forever.

Replay protection

The signature header embeds a Unix timestamp (t=<ts>) that is part of the HMAC input, so attackers cannot change the timestamp without invalidating the signature. Your responsibility is to enforce a freshness window:

  • Reject when now - t > 300 seconds (5 minutes). This is the industry-standard window (Stripe, Svix, GitHub).
  • Reject when t - now > 30 seconds. Small forward tolerance covers clock skew between Selgeo servers and your receiver.

Selgeo delivers most events within seconds of the originating action. A 5-minute window gives plenty of headroom for retries, queue delays, and load spikes while keeping the replay window small.

The verification examples below include this check. Do not remove it when you adapt them to your stack.

Verification examples

import crypto from 'node:crypto';

function verifyWebhookSignature(signingSecret, signatureHeader, rawBody) {
if (!signatureHeader) {
return false;
}

// Strip the whsec_ prefix and hex-decode to get raw key bytes
const rawSecret = signingSecret.replace(/^whsec_/, '');
const secretBytes = Buffer.from(rawSecret, 'hex');

// Parse the header: t=<ts>,v1=<hmac>
const parts = Object.fromEntries(
signatureHeader.split(',').map((part) => {
return part.split('=', 2);
})
);

const timestamp = parts.t;
const receivedHmac = parts.v1;

if (!timestamp || !receivedHmac) {
return false;
}

// Reject timestamps older than 5 minutes or in the future (with 30s tolerance)
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (age > 300 || age < -30) {
return false;
}

// Compute the expected HMAC
const expectedHmac = crypto
.createHmac('sha256', secretBytes)
.update(`${timestamp}.${rawBody}`)
.digest('hex');

// Constant-time comparison (buffers must be the same length)
const receivedBuf = Buffer.from(receivedHmac, 'hex');
const expectedBuf = Buffer.from(expectedHmac, 'hex');
if (receivedBuf.length !== expectedBuf.length) {
return false;
}
return crypto.timingSafeEqual(receivedBuf, expectedBuf);
}

// Usage in an Express handler
app.post('/webhooks/selgeo', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-selgeo-signature'];
const rawBody = req.body.toString();

if (!verifyWebhookSignature('whsec_YOUR_SIGNING_SECRET', signature, rawBody)) {
return res.status(401).send('Invalid signature');
}

const event = JSON.parse(rawBody);
console.log('Received event:', event.event_name);

// Process the event...

res.status(200).send('OK');
});
tip

Always use the raw request body string for signature verification, not a re-serialised version of the parsed JSON. Re-serialisation can change key ordering or whitespace, which invalidates the HMAC.

Event catalogue

Every webhook payload includes these envelope fields:

FieldTypeDescription
event_idstringUnique ID for this event (nanoid, 21 chars)
delivery_idstringUnique ID for this delivery attempt
event_namestringEvent type (see table below)
occurred_atstringISO 8601 timestamp
merchant_idstringYour merchant account ID
mode"test" | "live"Whether this event occurred in test or live mode
api_version"v1"API version

Partner events

EventDescription
participant.createdA new partner applied or was added to a programme
participant.approvedA partner was approved (manually or via auto-approval)
participant.rejectedA partner application was rejected
participant.suspendedAn active partner was suspended
participant.reinstatedA suspended partner was reinstated
participant.expiredA pending application was automatically expired after the platform TTL (no merchant action)
participant.erasedA partner's data was erased (GDPR)
participant.data_exportedA partner's data was exported (GDPR)
Example: participant.approved
{
"event_id": "evt_abc123def456ghi78",
"delivery_id": "dlv_xyz789abc012def34",
"event_name": "participant.approved",
"occurred_at": "2026-03-15T10:30:00.000Z",
"merchant_id": "mch_your_merchant_id",
"mode": "live",
"api_version": "v1",
"participant_id": "prt_partner_id",
"program_id": "prg_program_id",
"approved_at": "2026-03-15T10:30:00.000Z"
}

Attribution events

EventDescription
attribution.createdA click was attributed to a partner
attribution.convertedAn attributed click resulted in a conversion
attribution.expiredAn attribution window expired without conversion
attribution.duplicate_detectedA duplicate attribution was detected

Conversion events

EventDescription
conversion.createdA new conversion was recorded
conversion.fraud_detectedFraud was detected (self-referral or duplicate conversion)
Example: conversion.created
{
"event_id": "evt_abc123def456ghi78",
"delivery_id": "dlv_xyz789abc012def34",
"event_name": "conversion.created",
"occurred_at": "2026-03-15T14:22:00.000Z",
"merchant_id": "mch_your_merchant_id",
"mode": "live",
"api_version": "v1",
"conversion_id": "cnv_conversion_id",
"attribution_id": "att_attribution_id",
"participant_id": "prt_partner_id",
"program_id": "prg_program_id",
"amount_cents": 9900,
"currency": "EUR",
"conversion_scope": "first_conversion",
"created_at": "2026-03-15T14:22:00.000Z"
}
Example: conversion.fraud_detected
{
"event_id": "evt_abc123def456ghi78",
"delivery_id": "dlv_xyz789abc012def34",
"event_name": "conversion.fraud_detected",
"occurred_at": "2026-03-15T14:25:00.000Z",
"merchant_id": "mch_your_merchant_id",
"mode": "live",
"api_version": "v1",
"fraud_type": "self_referral",
"program_id": "prg_program_id",
"program_name": "My SaaS Affiliates",
"participant_id": "prt_partner_id",
"attribution_event_id": "att_attribution_id",
"conversion_id": null,
"external_transaction_id": "cs_live_abc123",
"masked_context": "us***@example.com",
"explanation": "Customer email matches partner email after RFC 5233 normalization",
"detected_at": "2026-03-15T14:25:00.000Z"
}

Commission events

EventDescription
commission.createdA new commission was calculated
commission.approvedA commission was approved for payout
commission.rejectedA commission was rejected
commission.hold_overriddenA commission's hold period was overridden (approved early)
commission.refundedA commission was reversed due to a refund
commission.paidA commission was included in a payout (coming soon)
Example: commission.created
{
"event_id": "evt_abc123def456ghi78",
"delivery_id": "dlv_xyz789abc012def34",
"event_name": "commission.created",
"occurred_at": "2026-03-15T14:22:05.000Z",
"merchant_id": "mch_your_merchant_id",
"mode": "live",
"api_version": "v1",
"commission_id": "cms_commission_id",
"conversion_id": "cnv_conversion_id",
"participant_id": "prt_partner_id",
"program_id": "prg_program_id",
"amount_cents": 1980,
"currency": "EUR",
"commission_type": "percentage",
"commission_rate": 20,
"needs_review": false,
"created_at": "2026-03-15T14:22:05.000Z"
}

Payout events

EventDescription
payout.paidA payout was marked as paid — its commissions moved to paid

The remaining payout events are reserved for a future automated payout-processing feature and are not emitted yet:

EventDescription
payout.createdA payout batch was created (coming soon)
payout.initiatedA payout transfer was initiated (coming soon)
payout.failedA payout transfer failed (coming soon)
payout.retriedA failed payout was retried (coming soon)
payout.cancelledA payout was cancelled (coming soon)

Tracking events

EventDescription
tracking_identifier.createdA new tracking link was created for a partner

Promo code events

EventDescription
promo_code.createdA promo code was created
promo_code.stripe_syncedA promo code was synced to Stripe
promo_code.deactivatedA promo code was deactivated

Programme invite events

EventDescription
program_invite.createdAn invite was created
program_invite.acceptedAn invite was accepted by a partner
program_invite.revokedAn invite was revoked
program_invite.expiredAn invite expired

Retry policy

If your endpoint returns a non-2xx status code or the request times out (30 seconds), Selgeo retries with exponential backoff:

AttemptDelay after failure
1st retry1 minute
2nd retry2 minutes
3rd retry4 minutes
4th retry15 minutes

After 5 total attempts (1 initial + 4 retries), the delivery moves to the dead-letter state. Dead-lettered deliveries are visible in the merchant dashboard under the endpoint's delivery log but are not retried automatically.

Delivery statuses

StatusMeaning
pendingQueued for delivery
deliveredYour endpoint returned 2xx
failedDelivery failed, retry scheduled
dead_letterAll retry attempts exhausted

Delivery log

Each endpoint has a delivery log in the dashboard (Settings → Webhooks → View deliveries). It shows the latest state of each event delivery — event id, event name, status, the number of attempts, the last response code, and timing — newest first, filterable by status and by event. Open a row to reveal the event payload that was sent and the last response body your endpoint returned.

Dead-lettered deliveries can be manually re-delivered from the log once you have fixed your endpoint. (Deliveries still in the automatic retry schedule — status failed — are not manually re-deliverable; they retry on their own.) The log reflects the latest state per delivery, not a per-attempt timeline, and delivery records are retained for 90 days.

Best practices

  • Return 200 quickly. Acknowledge receipt with a 200 status code as soon as you receive the webhook. Process the event asynchronously in a background job. Selgeo times out after 30 seconds.
  • Handle duplicates. Use the event_id field to deduplicate. The same event may be delivered more than once in rare cases (e.g., network timeout right after your server processed it).
  • Verify signatures. Always verify the X-Selgeo-Signature header. Never process unverified payloads.
  • Check the timestamp. Reject signatures with timestamps older than 5 minutes or too far in the future to prevent replay attacks.
  • Use HTTPS everywhere. All endpoints — test and live — require HTTPS on a publicly reachable host. For local development, expose your server through an HTTPS tunnel (for example, ngrok).
  • Monitor dead-letter deliveries. Check the delivery log in the dashboard periodically. Dead-lettered events may indicate an endpoint outage or a bug in your handler.