refVenuerefVenue
Docs
Webhook API

Webhook API

Receive real-time notifications when conversions, approvals, payouts, and other events occur

Webhook API

Receive real-time notifications from refVenue when important events occur in your affiliate program. Instead of polling our API, you register an HTTPS endpoint and refVenue pushes signed JSON events to it as they happen.

What are Webhooks?

Webhooks are automated HTTP requests sent from refVenue to your server when specific events happen:

  • A new conversion is tracked
  • A conversion is approved, rejected, or refunded
  • An affiliate joins one of your programs
  • A commission payout is processed

Each delivery is a POST request with a JSON body and a set of signing headers you can use to verify the request really came from refVenue.

Use Cases

When to use webhooks:

  • Sync conversion data to your CRM
  • Trigger custom notifications
  • Update your internal analytics
  • Automate affiliate onboarding
  • React to commission payouts

When NOT to use webhooks:

  • Simple conversion tracking — use JavaScript Tracking or the Server-Side API instead
  • One-way tracking only — webhooks are for receiving data from refVenue, not sending it

Setup Guide

Step 1: Register an Endpoint

Webhook endpoints are managed in the dashboard at Settings → Webhooks.

  1. Open Settings → Webhooks.
  2. Click Add Endpoint.
  3. Enter your endpoint URL. The URL must use https — plain http URLs are rejected.
  4. Choose which events to subscribe to. Leaving the selection empty means the endpoint receives all events.
  5. Optionally scope the endpoint to a single program. If you leave the program unset, the endpoint receives matching events across all of your programs.
  6. Save. refVenue generates a unique signing secret for the endpoint (prefixed whsec_).

You can reveal the signing secret, rotate it, or disable the endpoint at any time from Settings → Webhooks. Rotating the secret invalidates the previous one immediately, so update your server's stored secret when you rotate.

refVenue also auto-disables an endpoint after 15 consecutive failed deliveries. A disabled endpoint stops receiving events until you re-enable it in Settings → Webhooks; re-enabling it clears the failure streak.

Step 2: Build a Receiving Endpoint

Create an endpoint on your server that accepts POST requests and returns a 2xx status code:

// Express.js example (signature verification added in Step 3)
app.post('/webhooks/refvenue', (req, res) => {
  const event = req.body;
 
  console.log('Received event:', event.type);
 
  // Return 2xx to acknowledge receipt
  res.status(200).send('OK');
});

Requirements:

  • Accepts POST requests
  • Returns a 2xx status code within 10 seconds
  • Publicly accessible HTTPS URL (not localhost)

Step 3: Verify the Signature

Always verify that a request actually came from refVenue before trusting it. See Verifying Signatures below for the exact scheme and language-specific examples.

Request Format

Each delivery is an HTTP POST to your endpoint URL. The body is the JSON event envelope (described below), and refVenue sends these headers:

HeaderDescription
Content-TypeAlways application/json.
User-AgentAlways refVenue-Webhooks/1.0.
x-refvenue-eventThe event name, e.g. conversion.created.
x-refvenue-event-idA unique id for this event, e.g. evt_.... Use it to deduplicate — deliveries are at-least-once.
x-refvenue-timestampThe unix time (seconds) when the request was signed.
x-refvenue-signatureHex-encoded HMAC-SHA256 signature of the request (see below).

Event Envelope

Every event — regardless of type — uses the same top-level envelope. The event-specific fields live under data:

{
  "id": "evt_3f9a1c8e0b2d4a6f8c1e3b5d7a9f0c2e",
  "type": "conversion.created",
  "created": 1700000000,
  "data": {}
}
  • id — the unique event id (also sent in the x-refvenue-event-id header).
  • type — the event name (also sent in the x-refvenue-event header).
  • created — unix time in seconds when the event was created.
  • data — the event-specific payload, documented per event below.

Webhook Events

conversion.created

Triggered when a new conversion is tracked.

{
  "id": "evt_a1b2c3d4e5f6",
  "type": "conversion.created",
  "created": 1700000000,
  "data": {
    "id": "conv_abc123",
    "programId": "prog_xyz789",
    "affiliateId": "aff_def456",
    "conversionType": "PURCHASE",
    "revenueAmount": 99.99,
    "commissionAmount": 9.99,
    "status": "PENDING",
    "customerEmail": "customer@example.com",
    "isRecurring": false
  }
}
  • conversionType is either "SIGNUP" or "PURCHASE".
  • revenueAmount is a number, or null when there is no associated revenue (for example, signup conversions).
  • commissionAmount is a number.
  • isRecurring is a boolean.

conversion.approved

Triggered when a conversion is approved.

{
  "id": "evt_b2c3d4e5f6a1",
  "type": "conversion.approved",
  "created": 1700000100,
  "data": {
    "id": "conv_abc123",
    "programId": "prog_xyz789",
    "affiliateId": "aff_def456",
    "status": "APPROVED",
    "commissionAmount": 9.99,
    "customerEmail": "customer@example.com"
  }
}

conversion.rejected

Triggered when a conversion is rejected.

{
  "id": "evt_c3d4e5f6a1b2",
  "type": "conversion.rejected",
  "created": 1700000200,
  "data": {
    "id": "conv_abc123",
    "programId": "prog_xyz789",
    "affiliateId": "aff_def456",
    "status": "REJECTED",
    "commissionAmount": 9.99,
    "customerEmail": "customer@example.com"
  }
}

conversion.refunded

Triggered when a conversion is refunded or charged back.

{
  "id": "evt_d4e5f6a1b2c3",
  "type": "conversion.refunded",
  "created": 1700000300,
  "data": {
    "id": "conv_abc123",
    "programId": "prog_xyz789",
    "affiliateId": "aff_def456",
    "status": "REFUNDED",
    "commissionAmount": 9.99,
    "customerEmail": "customer@example.com"
  }
}

The status field is "REFUNDED" for a refund, or "CHARGEBACK" when the reversal was triggered by a chargeback.

affiliate.joined

Triggered when an affiliate joins one of your programs.

{
  "id": "evt_e5f6a1b2c3d4",
  "type": "affiliate.joined",
  "created": 1700000400,
  "data": {
    "id": "aff_def456",
    "programId": "prog_xyz789",
    "affiliateId": "aff_def456",
    "name": "John Doe",
    "email": "john@example.com",
    "uniqueCode": "JOHN123",
    "status": "PENDING",
    "source": "marketplace"
  }
}

payout.processed

Triggered when a commission payout is processed.

{
  "id": "evt_f6a1b2c3d4e5",
  "type": "payout.processed",
  "created": 1700000500,
  "data": {
    "id": "payout_ghi789",
    "programId": "prog_xyz789",
    "affiliateId": "aff_def456",
    "amount": 150.00,
    "currency": "USD",
    "status": "COMPLETED",
    "conversionCount": 12
  }
}
  • amount is a number.
  • conversionCount is the number of conversions included in the payout.

webhook.test

The Send test button on each endpoint in Settings → Webhooks delivers a webhook.test event so you can confirm your endpoint receives and verifies deliveries before going live:

{
  "id": "evt_0011223344556677",
  "type": "webhook.test",
  "created": 1700000600,
  "data": {
    "message": "This is a test event from refVenue."
  }
}

Verifying Signatures

Every request includes a signature so you can confirm it came from refVenue and was not tampered with.

The signature is computed as:

HMAC_SHA256(signingSecret, "{x-refvenue-timestamp}.{rawRequestBody}")

hex-encoded, and sent in the x-refvenue-signature header. Note that the signed string is the timestamp, a literal . (period), and then the raw request body — not the body alone.

To verify a request:

  1. Read the raw request body as bytes/string, before any JSON parsing. Your framework must not pre-parse the body, because re-serializing parsed JSON can change the bytes and break the signature.
  2. Recompute HMAC_SHA256(secret, timestamp + "." + rawBody) using the x-refvenue-timestamp header value and your endpoint's signing secret.
  3. Compare the result to the x-refvenue-signature header using a timing-safe comparison.
  4. Reject the request if the x-refvenue-timestamp is more than about 5 minutes old. This protects against replayed requests.

Node.js / Express

const express = require('express');
const crypto = require('crypto');
 
const app = express();
const WEBHOOK_SECRET = process.env.REFVENUE_WEBHOOK_SECRET; // whsec_...
 
// Capture the RAW body — do not let express.json() parse it first.
app.use('/webhooks/refvenue', express.raw({ type: 'application/json' }));
 
function verify(rawBody, timestamp, signature, secret) {
  // Reject requests older than 5 minutes (replay protection).
  const age = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (!timestamp || Number.isNaN(age) || Math.abs(age) > 300) {
    return false;
  }
 
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');
 
  const a = Buffer.from(signature || '', 'hex');
  const b = Buffer.from(expected, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
 
app.post('/webhooks/refvenue', (req, res) => {
  const rawBody = req.body.toString('utf8');
  const timestamp = req.headers['x-refvenue-timestamp'];
  const signature = req.headers['x-refvenue-signature'];
 
  if (!verify(rawBody, timestamp, signature, WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }
 
  const event = JSON.parse(rawBody);
  const eventId = req.headers['x-refvenue-event-id'];
 
  // Acknowledge fast, then process asynchronously (and dedupe on eventId).
  res.status(200).send('OK');
  processEvent(eventId, event).catch(console.error);
});
 
app.listen(3000);

Python / Flask

import hashlib
import hmac
import os
import time
 
from flask import Flask, request
 
app = Flask(__name__)
WEBHOOK_SECRET = os.environ['REFVENUE_WEBHOOK_SECRET']  # whsec_...
 
 
def verify(raw_body, timestamp, signature):
    # Reject requests older than 5 minutes (replay protection).
    try:
        age = int(time.time()) - int(timestamp)
    except (TypeError, ValueError):
        return False
    if abs(age) > 300:
        return False
 
    signed = f"{timestamp}.{raw_body}".encode()
    expected = hmac.new(WEBHOOK_SECRET.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature or '', expected)
 
 
@app.route('/webhooks/refvenue', methods=['POST'])
def webhook():
    # Read the RAW body before any JSON parsing.
    raw_body = request.get_data(as_text=True)
    timestamp = request.headers.get('X-Refvenue-Timestamp')
    signature = request.headers.get('X-Refvenue-Signature')
 
    if not verify(raw_body, timestamp, signature):
        return 'Invalid signature', 401
 
    import json
    event = json.loads(raw_body)
    event_id = request.headers.get('X-Refvenue-Event-Id')
 
    # Dedupe on event_id, then handle the event.
    handle_event(event_id, event)
    return 'OK', 200
 
 
if __name__ == '__main__':
    app.run(port=3000)

PHP

<?php
$webhookSecret = getenv('REFVENUE_WEBHOOK_SECRET'); // whsec_...
 
// Read the RAW body — do not json_decode before verifying.
$rawBody   = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_REFVENUE_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_REFVENUE_SIGNATURE'] ?? '';
 
// Reject requests older than 5 minutes (replay protection).
if ($timestamp === '' || abs(time() - (int) $timestamp) > 300) {
    http_response_code(401);
    exit('Invalid timestamp');
}
 
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $webhookSecret);
 
if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Invalid signature');
}
 
$event   = json_decode($rawBody, true);
$eventId = $_SERVER['HTTP_X_REFVENUE_EVENT_ID'] ?? '';
 
// Dedupe on $eventId, then handle the event.
handleEvent($eventId, $event);
 
http_response_code(200);
echo 'OK';

Delivery and Retries

Deliveries are at-least-once: a single event may be delivered more than once, so your handler must tolerate duplicates.

  • A 2xx response marks the delivery as successful.
  • Any non-2xx response, or a timeout (refVenue waits up to 10 seconds), counts as a failure and is retried.
  • Failed deliveries are retried with exponential backoff, up to 6 attempts total, spaced roughly: 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours.
  • After 15 consecutive failed deliveries, the endpoint is auto-disabled and must be re-enabled in Settings → Webhooks.

Events are not guaranteed to arrive in order — for example, a retried conversion.created may arrive after its conversion.approved. Use the created timestamp and the resource ids in data to reconcile state rather than relying on arrival order.

Best Practices

1. Return 2xx Quickly, Process Asynchronously

Acknowledge the delivery immediately, then do your work in the background so you never hit the 10-second timeout:

app.post('/webhooks/refvenue', (req, res) => {
  // ...verify signature first...
 
  res.status(200).send('OK'); // acknowledge fast
  processWebhook(event).catch(console.error); // then process
});

2. Deduplicate on x-refvenue-event-id

Because delivery is at-least-once, the same event id may arrive more than once. Treat x-refvenue-event-id as an idempotency key:

async function processEvent(eventId, event) {
  const seen = await db.webhookEvents.findOne({ eventId });
  if (seen) return; // already handled
 
  await db.webhookEvents.create({ eventId, type: event.type });
  await handleEvent(event);
}

3. Always Verify the Signature

Never trust an unverified request. Recompute the HMAC over timestamp + "." + rawBody, compare in constant time, and reject stale timestamps — see Verifying Signatures.

4. Tolerate Out-of-Order Delivery

Don't assume events arrive in the order they occurred. Make your handlers safe to apply in any order, and fall back to the resource ids and the created timestamp to determine the correct final state.

Testing Webhooks

Send a Test Event

From Settings → Webhooks, open an endpoint and click Send test. refVenue delivers a webhook.test event (payload shown in webhook.test above) so you can confirm your endpoint receives, verifies, and responds correctly.

Local Development

Your endpoint must be a publicly reachable HTTPS URL, so you cannot point refVenue directly at localhost. During development, use a tunneling tool such as ngrok to expose your local server over HTTPS:

# Expose local port 3000 over a public HTTPS URL
ngrok http 3000
 
# Register the generated https URL in Settings → Webhooks, e.g.
# https://abc123.ngrok.io/webhooks/refvenue

Troubleshooting

Webhooks Not Received

Check:

  1. The endpoint URL is publicly accessible over HTTPS (not localhost).
  2. Your endpoint returns a 2xx status code.
  3. No firewall is blocking inbound requests.
  4. The endpoint is enabled — confirm it has not been auto-disabled after repeated failures in Settings → Webhooks.
  5. The endpoint is subscribed to the event type you expect (an empty subscription means all events).

Signature Verification Fails

Ensure:

  1. You are using the correct signing secret (whsec_...) for that endpoint, and you updated it if the secret was rotated.
  2. You sign over timestamp + "." + rawBody, in that exact order, with a literal . separator.
  3. You read the raw request body and do not let your framework parse and re-serialize the JSON first.
  4. You compare signatures with a timing-safe function and use SHA-256.
  5. The x-refvenue-timestamp is within your allowed window (about 5 minutes).

Timeout Errors

Solutions:

  1. Return a 2xx response immediately.
  2. Process events asynchronously.
  3. Keep the request handler under the 10-second delivery timeout.

Duplicate Events

This is expected:

  • Delivery is at-least-once, so the same event id can arrive more than once.
  • Retries and network conditions can cause duplicates.
  • Deduplicate on x-refvenue-event-id and keep handlers idempotent.

Alternative Integration Methods

Webhooks are just one way to integrate:

Support