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. Live-mode endpoints must use HTTPS. Test-mode endpoints may use HTTP for local development.
  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-serialized version of the parsed JSON. Re-serialization can change key ordering or whitespace, which invalidates the HMAC.

Event catalog

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 program
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.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.paidA commission was included in a payout (coming soon)
commission.refundedA commission was reversed due to a refund
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 (coming soon)

Payout events will be available when the payout processing feature launches.

EventDescription
payout.createdA payout batch was created
payout.initiatedA payout transfer was initiated
payout.paidA payout was successfully transferred
payout.failedA payout transfer failed
payout.retriedA failed payout was retried
payout.cancelledA payout was cancelled

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

Program 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

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 in production. Live-mode endpoints require HTTPS. Test-mode endpoints allow HTTP for local development convenience.
  • 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.