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.
- Click Add endpoint.
- Enter the URL where you want to receive events. Live-mode endpoints must use HTTPS. Test-mode endpoints may use HTTP for local development.
- Select the events you want to subscribe to.
- 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.
Your endpoint MUST reject requests that fail either check:
- HMAC signature must match the header's
v1=<hmac>value (use a constant-time comparison). - Timestamp must be recent — reject if
now - t > 300seconds ort - now > 30seconds.
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 > 300seconds (5 minutes). This is the industry-standard window (Stripe, Svix, GitHub). - Reject when
t - now > 30seconds. 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
- Node.js
- Python
- PHP
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');
});
import hashlib
import hmac
import time
def verify_webhook_signature(signing_secret: str, signature_header: str, raw_body: str) -> bool:
if not signature_header:
return False
# Strip the whsec_ prefix and hex-decode to get raw key bytes
raw_secret = signing_secret.removeprefix("whsec_")
secret_bytes = bytes.fromhex(raw_secret)
# Parse the header: t=<ts>,v1=<hmac>
try:
parts = dict(part.split("=", 1) for part in signature_header.split(","))
except ValueError:
return False
timestamp = parts.get("t")
received_hmac = parts.get("v1")
if not timestamp or not received_hmac:
return False
# Reject timestamps older than 5 minutes or in the future (with 30s tolerance)
try:
age = int(time.time()) - int(timestamp)
except ValueError:
return False
if age > 300 or age < -30:
return False
# Compute the expected HMAC
message = f"{timestamp}.{raw_body}".encode()
expected_hmac = hmac.new(secret_bytes, message, hashlib.sha256).hexdigest()
# Constant-time comparison
return hmac.compare_digest(received_hmac, expected_hmac)
# Usage in a Flask handler
@app.route("/webhooks/selgeo", methods=["POST"])
def handle_webhook():
signature = request.headers.get("X-Selgeo-Signature", "")
raw_body = request.get_data(as_text=True)
if not verify_webhook_signature("whsec_YOUR_SIGNING_SECRET", signature, raw_body):
return "Invalid signature", 401
event = request.get_json()
print(f"Received event: {event['event_name']}")
# Process the event...
return "OK", 200
<?php
function verifyWebhookSignature(
string $signingSecret,
string $signatureHeader,
string $rawBody
): bool {
if ($signatureHeader === '') {
return false;
}
// Strip the whsec_ prefix and hex-decode to get raw key bytes
$rawSecret = str_starts_with($signingSecret, 'whsec_')
? substr($signingSecret, 6)
: $signingSecret;
$secretBytes = hex2bin($rawSecret);
if ($secretBytes === false) {
return false;
}
// Parse the header: t=<ts>,v1=<hmac>
$parts = [];
foreach (explode(',', $signatureHeader) as $part) {
$segments = explode('=', $part, 2);
if (count($segments) !== 2) {
continue;
}
[$key, $value] = $segments;
$parts[$key] = $value;
}
$timestamp = $parts['t'] ?? null;
$receivedHmac = $parts['v1'] ?? null;
if (!$timestamp || !$receivedHmac) {
return false;
}
// Reject timestamps older than 5 minutes or in the future (with 30s tolerance)
$age = time() - (int) $timestamp;
if ($age > 300 || $age < -30) {
return false;
}
// Compute the expected HMAC
$expectedHmac = hash_hmac('sha256', "{$timestamp}.{$rawBody}", $secretBytes);
// Constant-time comparison
return hash_equals($expectedHmac, $receivedHmac);
}
// Usage
$signature = $_SERVER['HTTP_X_SELGEO_SIGNATURE'] ?? '';
$rawBody = file_get_contents('php://input');
if (!verifyWebhookSignature('whsec_YOUR_SIGNING_SECRET', $signature, $rawBody)) {
http_response_code(401);
echo 'Invalid signature';
exit;
}
$event = json_decode($rawBody, true);
error_log("Received event: " . $event['event_name']);
// Process the event...
http_response_code(200);
echo 'OK';
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:
| Field | Type | Description |
|---|---|---|
event_id | string | Unique ID for this event (nanoid, 21 chars) |
delivery_id | string | Unique ID for this delivery attempt |
event_name | string | Event type (see table below) |
occurred_at | string | ISO 8601 timestamp |
merchant_id | string | Your merchant account ID |
mode | "test" | "live" | Whether this event occurred in test or live mode |
api_version | "v1" | API version |
Partner events
| Event | Description |
|---|---|
participant.created | A new partner applied or was added to a program |
participant.approved | A partner was approved (manually or via auto-approval) |
participant.rejected | A partner application was rejected |
participant.suspended | An active partner was suspended |
participant.reinstated | A suspended partner was reinstated |
participant.erased | A partner's data was erased (GDPR) |
participant.data_exported | A 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
| Event | Description |
|---|---|
attribution.created | A click was attributed to a partner |
attribution.converted | An attributed click resulted in a conversion |
attribution.expired | An attribution window expired without conversion |
attribution.duplicate_detected | A duplicate attribution was detected |
Conversion events
| Event | Description |
|---|---|
conversion.created | A new conversion was recorded |
conversion.fraud_detected | Fraud 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
| Event | Description |
|---|---|
commission.created | A new commission was calculated |
commission.approved | A commission was approved for payout |
commission.rejected | A commission was rejected |
commission.paid | A commission was included in a payout (coming soon) |
commission.refunded | A 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.
| Event | Description |
|---|---|
payout.created | A payout batch was created |
payout.initiated | A payout transfer was initiated |
payout.paid | A payout was successfully transferred |
payout.failed | A payout transfer failed |
payout.retried | A failed payout was retried |
payout.cancelled | A payout was cancelled |
Tracking events
| Event | Description |
|---|---|
tracking_identifier.created | A new tracking link was created for a partner |
Promo code events
| Event | Description |
|---|---|
promo_code.created | A promo code was created |
promo_code.stripe_synced | A promo code was synced to Stripe |
promo_code.deactivated | A promo code was deactivated |
Program invite events
| Event | Description |
|---|---|
program_invite.created | An invite was created |
program_invite.accepted | An invite was accepted by a partner |
program_invite.revoked | An invite was revoked |
program_invite.expired | An 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:
| Attempt | Delay after failure |
|---|---|
| 1st retry | 1 minute |
| 2nd retry | 2 minutes |
| 3rd retry | 4 minutes |
| 4th retry | 15 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
| Status | Meaning |
|---|---|
pending | Queued for delivery |
delivered | Your endpoint returned 2xx |
failed | Delivery failed, retry scheduled |
dead_letter | All retry attempts exhausted |
Best practices
- Return 200 quickly. Acknowledge receipt with a
200status 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_idfield 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-Signatureheader. 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.