← Blog
2026-05-22

Copy Trading Webhooks: Standard Webhooks Signing and Idempotency in Practice

How to verify HMAC-SHA256 signatures, handle replays, and survive partial outages when consuming copy-trading webhook events.

When you're building a copy trading platform, knowing when trades happen is just as important as knowing what happened. CopyBridge's outbound webhooks deliver trade events to your server in real-time, letting you trigger notifications, update analytics dashboards, sync with CRMs, or execute custom business logic the moment a trade executes.

Unlike polling APIs that hammer your infrastructure every few seconds, webhooks push data to you instantly. This guide covers what events CopyBridge sends, how the signing mechanism works, and how to verify webhooks securely in production.

What Outbound Webhooks Deliver

CopyBridge sends HTTP POST requests to your configured endpoint whenever a provider's trade opens or closes, and when a follower joins or leaves. Each webhook contains:

  • id: Unique identifier for deduplication (also sent as the webhook-id header)
  • type: One of trade.opened, trade.closed, follower.created, follower.deactivated
  • created_at: ISO 8601 UTC timestamp
  • data: Event-specific payload — for trade events, the trade record plus the followers it applies to

A typical trade.closed event looks like this:

{
  "id": "8f14e45f-ceea-4b6b-9853-9c0e5a0f8c1f",
  "type": "trade.closed",
  "created_at": "2026-08-16T14:32:18.000Z",
  "data": {
    "trade": {
      "ticket": 85032471,
      "symbol": "EURUSD",
      "type": "buy",
      "action": "CLOSE",
      "lots": 0.1,
      "price": 1.08520,
      "magic": 123456,
      "timestamp": "2026-08-16T14:32:18.000Z"
    },
    "affected_followers": [
      { "id": "3c1e2b9a-..." }
    ]
  }
}

Configure your webhook URL in the dashboard or via API. CopyBridge delivers events from both Master EA (provider side) and Copier EA (follower side) depending on your integration needs.

Standard Webhooks Signing Specification

CopyBridge implements the Standard Webhooks specification for request signing. This industry standard is used by Stripe, Svix, and other platforms—meaning libraries and verification code work across services.

Every webhook request includes:

  • webhook-id: Unique message ID (same as id in the body)
  • webhook-timestamp: Unix timestamp (seconds since epoch)
  • webhook-signature: HMAC-SHA256 signature in format v1,{signature}

The signature covers the concatenation of webhook-id, webhook-timestamp, and the raw request body:

signed_content = webhook_id + "." + webhook_timestamp + "." + raw_body
signature = base64(hmac_sha256(key, signed_content))

The key is not your signing secret string — your secret is whsec_<base64>. Strip the whsec_ prefix and base64-decode the rest; the resulting bytes are the HMAC key. Signing with the raw secret string produces a signature that will never match a genuine delivery.

Your signing secret is available in the dashboard. Treat it like a password—never commit it to git or expose it client-side.

HMAC-SHA256 Verification in Node.js

Here's production-ready verification code for Express:

import crypto from 'crypto';

const WEBHOOK_SECRET = process.env.COPYBRIDGE_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300; // 5 minutes

function verifyWebhook(req, res, next) {
  const signature = req.headers['webhook-signature'];
  const id = req.headers['webhook-id'];
  const timestamp = req.headers['webhook-timestamp'];
  // express.raw() below puts the raw Buffer on req.body — there is no
  // req.rawBody unless you add a verify callback yourself. Parsing and
  // re-serialising reorders keys and breaks the signature, so keep it raw.
  const body = req.body;

  if (!signature || !id || !timestamp) {
    return res.status(401).json({ error: 'Missing webhook headers' });
  }

  // Check replay window
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp)) > TOLERANCE_SECONDS) {
    return res.status(401).json({ error: 'Timestamp outside tolerance window' });
  }

  // Compute expected signature. The secret is `whsec_` + base64 — strip the
  // prefix and decode it. The decoded bytes are the HMAC key, not the
  // secret string itself.
  const key = Buffer.from(WEBHOOK_SECRET.slice(6), 'base64');
  const signedContent = `${id}.${timestamp}.${body}`;
  const expectedSignature = crypto
    .createHmac('sha256', key)
    .update(signedContent, 'utf8')
    .digest('base64');

  // Extract signature from "v1,{sig}" format
  const providedSignature = signature.split(',')[1];

  // Constant-time comparison
  if (!crypto.timingSafeEqual(
    Buffer.from(expectedSignature),
    Buffer.from(providedSignature)
  )) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  next();
}

app.post('/webhooks/copybridge', 
  express.raw({ type: 'application/json' }), // Preserve raw body
  verifyWebhook,
  (req, res) => {
    const event = JSON.parse(req.body);
    
    // Process event
    console.log(`Received ${event.type} for ticket ${event.data.trade?.ticket}`);
    
    res.status(200).json({ received: true });
  }
);

Critical detail: you must verify the signature against the raw request body before JSON parsing. Express's express.raw() middleware preserves the original bytes.

Python Verification with Flask

Python verification follows the same logic:

import hmac
import hashlib
import base64
import time
from flask import Flask, request, jsonify

WEBHOOK_SECRET = os.environ['COPYBRIDGE_WEBHOOK_SECRET']
TOLERANCE_SECONDS = 300

app = Flask(__name__)

def verify_webhook():
    signature = request.headers.get('webhook-signature')
    webhook_id = request.headers.get('webhook-id')
    timestamp = request.headers.get('webhook-timestamp')
    body = request.get_data()
    
    if not all([signature, webhook_id, timestamp]):
        return False, 'Missing headers'
    
    # Check replay window
    now = int(time.time())
    if abs(now - int(timestamp)) > TOLERANCE_SECONDS:
        return False, 'Timestamp outside window'
    
    # Compute signature. The secret is `whsec_` + base64 — strip the prefix
    # and decode it. The decoded bytes are the HMAC key, not the secret string.
    key = base64.b64decode(WEBHOOK_SECRET[len('whsec_'):])
    signed_content = f"{webhook_id}.{timestamp}.{body.decode('utf-8')}"
    expected_signature = base64.b64encode(
        hmac.new(
            key,
            signed_content.encode('utf-8'),
            hashlib.sha256
        ).digest()
    ).decode('utf-8')
    
    # Extract from "v1,{sig}"
    provided_signature = signature.split(',')[1]
    
    # Constant-time comparison
    return hmac.compare_digest(expected_signature, provided_signature), None

@app.route('/webhooks/copybridge', methods=['POST'])
def handle_webhook():
    valid, error = verify_webhook()
    if not valid:
        return jsonify({'error': error}), 401
    
    event = request.get_json()
    
    # Process event
    print(f"Received {event['type']} for ticket {event['data']['trade']['ticket']}")
    
    return jsonify({'received': True}), 200

Both examples use constant-time comparison functions (crypto.timingSafeEqual in Node, hmac.compare_digest in Python) to prevent timing attacks.

Replay Window and Timestamp Validation

The 5-minute tolerance window prevents replay attacks. If an attacker captures a valid webhook and tries to resend it hours later, your server rejects it because the timestamp is stale.

This also handles clock skew between CopyBridge servers and your infrastructure. If your server's clock drifts by 2-3 minutes, webhooks still verify successfully.

For stricter security, reduce TOLERANCE_SECONDS to 60. For more lenient handling across data centers with significant clock drift, extend to 600 (10 minutes). Never go beyond 15 minutes.

Idempotency with the webhook-id Header

Networks fail, services restart, and webhooks sometimes deliver twice. Track the IDs you've already seen and skip duplicates. Prefer the webhook-id header over the body's id field as your dedup key — it's available (and already verified) before you even parse the JSON:

const processedEvents = new Set(); // Use Redis in production

app.post('/webhooks/copybridge', verifyWebhook, async (req, res) => {
  const webhookId = req.headers['webhook-id'];

  if (processedEvents.has(webhookId)) {
    return res.status(200).json({ received: true }); // Already processed
  }

  const event = JSON.parse(req.body);

  // Process event
  await updateAnalyticsDashboard(event);
  await sendTradeNotification(event);

  processedEvents.add(webhookId);
  res.status(200).json({ received: true });
});

In production, store webhook-id in Redis with a 7-day expiration. This handles retries while keeping memory usage bounded.

Retry Semantics and Backoff

CopyBridge's retry budget is small — build your endpoint to succeed on the first attempt rather than lean on retries to paper over slowness:

  • 2 attempts total, roughly 500 ms apart
  • Each attempt times out after 5 seconds
  • If both attempts fail (non-2xx response, or no response within the timeout), the delivery is marked failed — there is no further automatic retry

Recovery after that point is manual: POST /api/v1/providers/me/outbound-webhook/replay with { "event_id": "<uuid>" } (dashboard auth) re-sends a specific failed delivery under a new webhook-id. There is no "unhealthy endpoint" alert or dashboard toggle to re-enable webhooks — deliveries simply keep firing on the next event; only the failed one needs a manual replay if you want it redelivered.

Given that budget, your endpoint should:

  • Return 200 OK within 5 seconds — CopyBridge does not wait longer
  • Process events asynchronously (queue them for background workers)
  • Avoid blocking on external API calls in the webhook handler
// Good: queue for async processing
app.post('/webhooks/copybridge', verifyWebhook, async (req, res) => {
  const event = JSON.parse(req.body);
  
  await queue.enqueue('trade-events', event); // Fast write to queue
  
  res.status(200).json({ received: true }); // Return immediately
});

// Background worker processes queue
worker.process('trade-events', async (job) => {
  await sendEmailNotification(job.data); // Slow operation
  await updateCRM(job.data);
});

This pattern keeps webhook handlers fast and reliable.

Recovering a Failed Delivery with the Replay Endpoint

The replay endpoint isn't a synthetic-event generator — it re-sends a delivery that already happened and already failed (or was blocked or skipped). You can't hand it an arbitrary event_type/data pair; it looks up an existing row by event_id and re-emits that exact payload with a fresh webhook-id:

curl -X POST https://api.copybridge.io/api/v1/providers/me/outbound-webhook/replay \
  -H "Authorization: Bearer <your-provider-jwt>" \
  -H "Content-Type: application/json" \
  -d '{ "event_id": "8f14e45f-ceea-4b6b-9853-9c0e5a0f8c1f" }'

Notes on how it actually behaves:

  • Auth is the provider dashboard JWT (Authorization: Bearer ...), not X-API-Key.
  • event_id must reference a delivery already recorded for your provider, and its status must be failed, blocked, or skipped — replaying something already sent or pending returns 422.
  • The response is { webhook_id, replayed_at, status: "pending" } — the same delivery pipeline (and retry budget) as any other webhook.

Use it to recover after your endpoint was down, not to bootstrap signature-verification tests before you've received a single real delivery. For that, use sandbox mode with a tunnel (ngrok, Cloudflare Tunnel) pointed at your local machine, then trigger a real trade or follower event.

Webhook Security Checklist

Before going live, verify:

  • ✅ Signature verification implemented with constant-time comparison
  • ✅ Replay window enforcement (5 minutes or tighter)
  • ✅ Idempotency tracking via the webhook-id header
  • ✅ Webhook secret stored in environment variables, not code
  • ✅ Raw request body preserved for signature check
  • ✅ Async processing for slow operations
  • ✅ Response time under 5 seconds
  • ✅ Error handling for malformed events

Start Building

CopyBridge webhooks give you real-time visibility into every trade flowing through your copy trading platform. Whether you're building analytics dashboards, notification systems, or custom business logic, webhooks deliver the data you need exactly when you need it.

Read the full webhook reference docs for payload schemas and advanced configuration options. Test your implementation in sandbox mode before connecting real brokers. If you're building a white-label copy trading SaaS, explore the integrator API for multi-tenant webhook management.

Ready to integrate? Get your API key and start building today.