Webhooks

BrandRep sends webhooks so your engine doesn't have to poll. This page covers setup, signature verification, the delivery contract, and what to do on each event.

The payload schemas are in the Webhooks section of https://backend.brandrep.io/v1/docs — Swagger UI renders them with field-by-field annotations.


The five lifecycle events

EventWhen it fires
batch.startedThe operator worker picks up the batch (queuedrunning)
batch.completedBatch reaches any terminal state (completed / failed / cancelled)
message.sentAn outbound DM is confirmed delivered by the platform (not when queued)
message.failedAn outbound DM fails at the platform
message.repliedAn inbound reply lands against a lead

Plus one synthetic event for verification: webhook.test, fired by the dashboard's Verify button or by POST /v1/webhook_endpoints/{id}/test.


Setup

  1. In the BrandRep dashboard → Settings → WebhooksAdd endpoint.
  2. Enter your HTTPS URL (must start with https:// — plaintext is rejected).
  3. Pick events to subscribe to. "All events" is fine for v1 — you can ignore the ones you don't handle.
  4. Submit. The signing secret is shown once. It looks like:
whsec_K3jR2a8tB4qP7xV1m6...

Copy it now. Save it as BRANDREP_WEBHOOK_SECRET on your server. You cannot view it again — only rotate.

  1. Click Verify on the endpoint row. BrandRep fires a webhook.test event to your URL. Inline result shows the HTTP status your endpoint returned. Green check = wired up correctly.

Rotating the signing secret

There is no grace period in v1. The moment you click Rotate, the old secret is invalidated. Update your receiver to the new secret before rotating, not after.

Managing endpoints via the API

Webhook endpoints can also be created and managed using your API key (no browser required). This is useful when you are managing multiple brands programmatically or running headless automation.

# Create an endpoint
curl -sX POST \
  -H "Authorization: Bearer $BR_KEY" \
  -H "Content-Type: application/json" \
  "https://backend.brandrep.io/v1/brands/$BRAND/webhook_endpoints" \
  -d '{
    "url": "https://your-server.example.com/brandrep-webhooks",
    "enabled_events": ["message.sent", "message.replied", "batch.completed"],
    "description": "Production handler"
  }'

The signing secret is returned once in the response as signing_secret. It will not be shown again.

Other API-key routes for endpoint management:

MethodPathWhat it does
GET/v1/brands/{brand_id}/webhook_endpointsList all endpoints for the brand
POST/v1/brands/{brand_id}/webhook_endpointsCreate an endpoint (signing secret returned once)
GET/v1/webhook_endpoints/{id}Get a single endpoint
PATCH/v1/webhook_endpoints/{id}Update URL, events, filters, or status
DELETE/v1/webhook_endpoints/{id}Remove an endpoint
POST/v1/webhook_endpoints/{id}/rotate-secretRotate the signing secret
POST/v1/webhook_endpoints/{id}/testFire a webhook.test event

Filtering deliveries by campaign or sending account

By default an endpoint receives every event for its brand. If you want to fan out events — e.g. one handler per brand department, or one endpoint per customer tenant in your system — you can whitelist a subset of campaigns or sending accounts.

Pass campaign_ids and/or social_account_ids when creating or updating an endpoint:

curl -sX POST \
  -H "Authorization: Bearer $BR_KEY" \
  -H "Content-Type: application/json" \
  "https://backend.brandrep.io/v1/brands/$BRAND/webhook_endpoints" \
  -d '{
    "url": "https://your-server.example.com/summer-campaign-hooks",
    "enabled_events": ["*"],
    "campaign_ids": ["<campaign-uuid-1>", "<campaign-uuid-2>"],
    "social_account_ids": []
  }'

Filter semantics:

ValueBehaviour
null or []No filter — endpoint receives all events for this brand
["<uuid>", ...]Whitelist — endpoint receives events only for these IDs

Both filters are independent. An event must pass both whitelists to be delivered (AND logic, not OR).

The dimension rule. An event that carries no campaign dimension is not dropped by a campaign whitelist. Whitelists filter events that have the dimension — so your endpoint won't silently miss dimensionless event types just because you've filtered to specific campaigns. If future event types are added with no campaign_id, a campaign-filtered endpoint will still receive them.

Updating filters on an existing endpoint — send only the keys you want to change:

curl -sX PATCH \
  -H "Authorization: Bearer $BR_KEY" \
  -H "Content-Type: application/json" \
  "https://backend.brandrep.io/v1/webhook_endpoints/$EP_ID" \
  -d '{"campaign_ids": ["<campaign-uuid-3>"]}'

Pass "campaign_ids": null or "campaign_ids": [] to clear the filter (receive all campaigns again).


The envelope

Every webhook request, regardless of event type, has the same envelope:

{
  "id": "5e1c8f9a-2b3d-4c7e-8a1f-9b0e7d6c5a4b",
  "type": "message.sent",
  "created_at": "2026-06-04T09:14:02Z",
  "occurred_at": "2026-06-04T09:14:00Z",
  "brand_id": "0b8e1f1a-...",
  "data": { /* event-type-specific */ }
}
FieldMeaning
idStable per logical event. Dedupe on this. Same event delivered twice has the same id.
typeEvent type string.
created_atWhen BrandRep enqueued the delivery.
occurred_atWhen the underlying state change happened. Use this for temporal logic — ordering is not guaranteed.
brand_idWhich brand this event belongs to.
dataEvent-specific payload (see below). Customer-supplied refs (campaign_external_ref, lead_external_ref) are inside data, not at the envelope root.

Headers sent with every request:

Content-Type: application/json
User-Agent: BrandRep-Webhooks/1.0
BrandRep-Event-Id: 5e1c8f9a-2b3d-4c7e-8a1f-9b0e7d6c5a4b
BrandRep-Event-Type: message.sent
BrandRep-Signature: t=1717500000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d68d
BrandRep-Delivery-Id: 7f2d9a3e-4c1b-4e8d-9f0a-1b3c5d7e9f0a
BrandRep-Attempt: 1
  • BrandRep-Event-Id is the dedupe key. Same across retries.
  • BrandRep-Delivery-Id is different on each retry attempt — useful in your logs to distinguish attempts.
  • BrandRep-Attempt is 1-indexed.

Verifying the signature

The BrandRep-Signature header has two parts:

t=<unix-timestamp>,v1=<hex-hmac-sha256>

To verify:

  1. Reject if |now - t| > 300 seconds (5-minute replay window).
  2. Compute hmac_sha256(secret, f"{t}.".encode() + raw_request_body).
  3. Compare in constant time against the provided v1 hex string.

Python

import hmac
import hashlib
import time

def verify_brandrep(headers: dict, raw_body: bytes, secret: str) -> bool:
    sig = headers.get("BrandRep-Signature", "")
    parts = dict(p.split("=", 1) for p in sig.split(","))
    try:
        t = int(parts["t"])
        v1 = parts["v1"]
    except (KeyError, ValueError):
        return False

    # Replay window
    if abs(time.time() - t) > 300:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{t}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(v1, expected)

Important: pass the raw request body. If your framework JSON-decodes first and you re-serialise, byte-level differences (whitespace, key order) will break the signature.

In FastAPI:

@app.post("/brandrep-webhooks")
async def receive(request: Request):
    raw = await request.body()
    if not verify_brandrep(dict(request.headers), raw, BRANDREP_WEBHOOK_SECRET):
        raise HTTPException(401, "bad signature")
    payload = json.loads(raw)
    # ... process
    return {"ok": True}

In Flask:

@app.route("/brandrep-webhooks", methods=["POST"])
def receive():
    raw = request.get_data()
    if not verify_brandrep(dict(request.headers), raw, BRANDREP_WEBHOOK_SECRET):
        abort(401)
    payload = request.get_json()
    # ...

Node (Express)

const crypto = require("crypto");

function verifyBrandRep(headers, rawBody, secret) {
  const sig = headers["brandrep-signature"] || "";
  const parts = Object.fromEntries(sig.split(",").map(p => p.split("=")));
  const t = parseInt(parts.t, 10);
  const v1 = parts.v1;
  if (!t || !v1) return false;

  if (Math.abs(Date.now() / 1000 - t) > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.`)
    .update(rawBody)
    .digest("hex");

  const a = Buffer.from(v1, "hex");
  const b = Buffer.from(expected, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Mount the raw-body parser so rawBody is bytes, not a parsed object
app.post(
  "/brandrep-webhooks",
  express.raw({ type: "application/json" }),
  (req, res) => {
    if (!verifyBrandRep(req.headers, req.body, process.env.BRANDREP_WEBHOOK_SECRET)) {
      return res.status(401).end();
    }
    const payload = JSON.parse(req.body);
    // ... process
    res.status(200).end();
  }
);

The delivery contract (must read)

These are the four properties you must build your receiver around.

1. At-least-once delivery

A successful state change guarantees the corresponding event is eventually delivered. But it may be delivered more than once — the dispatcher will retry if your response is lost in transit, if our worker crashes after POST but before marking the delivery succeeded, or in any other partial-failure condition.

2. You MUST dedupe on BrandRep-Event-Id

Persist seen event IDs for at least 7 days (longer than our 8-attempt retry horizon of ~44 hours). Drop any event whose ID has already been processed.

A common pattern:

CREATE TABLE seen_webhook_events (
  event_id UUID PRIMARY KEY,
  seen_at TIMESTAMPTZ DEFAULT now()
);
-- Periodic cleanup: DELETE WHERE seen_at < now() - interval '14 days';
def is_duplicate(event_id: str, db) -> bool:
    try:
        db.execute(
            "INSERT INTO seen_webhook_events (event_id) VALUES (%s)",
            (event_id,),
        )
        return False
    except IntegrityError:
        return True

3. Ordering is NOT guaranteed

Events for the same lead may arrive out of order. The wire path runs through retries and parallel dispatchers, neither of which preserves source-side timing. Use occurred_at from the payload, not delivery order, when you need temporal reasoning.

4. 2xx is the only acknowledgment

Any response other than 2xx is treated as a failure and triggers retry. Return 2xx after the event has been durably persisted on your side, not before. If you 2xx and then crash before writing to your DB, the event is gone from our retry queue — the receiver owns durability after the ack.


Retry behaviour

Failed deliveries are retried on this schedule:

AttemptBackoff before
10s (immediate)
230s
35 min
430 min
52 hours
65 hours
712 hours
824 hours

After 8 attempts, the delivery dead-letters and is surfaced in the dashboard under Settings → Webhooks → Recent deliveries. Total retry horizon: ~44 hours.

What counts as "failed"

ResponseWhat we do
2xxsucceeded — done
4xx (except 408, 429)failed immediately — receiver's bug, no retries
408 / 429 / 5xx / network error / timeoutRetry on the backoff schedule
No response within 10sTreated as timeout → retry

A consistent 4xx kills delivery immediately. If your receiver returns 401 because of a temporary auth glitch, every event during the outage will dead-letter. Return 5xx for transient problems so we retry.


What to do on each event

Reference recipes. Adapt for your data model.

batch.started

Fires up to ~30 seconds after POST /v1/campaigns/{id}/batches returns 202. The operator-side worker polls for new batches on that interval; the delay is normal cadence, not a failure mode. Don't treat its absence inside the first half-minute as an error.

def on_batch_started(payload):
    batch_id = payload["data"]["batch_id"]
    db.update_my_batch(batch_id, state="in_flight")
    # That's it — log it, surface it in your UI if you want.

message.sent

data fields:

FieldTypeNotes
campaign_pipeline_idUUIDBrandRep lead (campaign_pipeline row) ID
message_idUUIDBrandRep message ID
campaign_idUUIDWhich campaign this lead belongs to
campaign_external_refstring | nullYour ID for the campaign; set via PATCH /v1/campaigns/{id}
lead_external_refstring | nullYour ID for this lead; set via PATCH /v1/campaigns/{id}/leads/external_refs
social_account_idUUID | nullThe burner account that sent this message
social_account_handlestring | nullHuman-readable handle of the sending account
sent_atISO 8601 stringWhen the platform confirmed delivery
def on_message_sent(payload):
    lead_id = payload["data"]["campaign_pipeline_id"]
    msg_id = payload["data"]["message_id"]
    sent_at = payload["data"]["sent_at"]
    # Route on your own IDs if you've tagged them:
    lead_ext = payload["data"]["lead_external_ref"]
    campaign_ext = payload["data"]["campaign_external_ref"]
    db.record_outbound(lead_ext or lead_id, msg_id, sent_at)

message.failed

data fields: same as message.sent plus:

FieldTypeNotes
failure_reasonstring | nullWhy the platform rejected the send
def on_message_failed(payload):
    lead_id = payload["data"]["campaign_pipeline_id"]
    reason = payload["data"]["failure_reason"]
    db.record_failure(lead_id, reason)
    # Decide your retry / different-channel logic here.

message.replied

data fields: same as message.sent (no sent_at; social_account_id / social_account_handle refer to the account the reply was received on).

def on_message_replied(payload):
    lead_id = payload["data"]["campaign_pipeline_id"]
    msg_id = payload["data"]["message_id"]
    lead_ext = payload["data"]["lead_external_ref"]
    # Hydrate the full message body if you need it:
    # body = brandrep.get_message(msg_id).body
    notify_agent(lead_ext or lead_id, msg_id)

batch.completed

This is the agent's wait-loop exit condition. Without it, edge cases like halted accounts or skipped leads (which fire no per-message events) would leave the agent hanging.

def on_batch_completed(payload):
    batch_id = payload["data"]["batch_id"]
    status = payload["data"]["status"]  # "completed" | "failed" | "cancelled"
    counts = {
        "sent": payload["data"]["sent_count"],
        "failed": payload["data"]["failed_count"],
        "skipped": payload["data"]["skipped_count"],
    }
    db.finalize_my_batch(batch_id, status, counts)
    agent.unblock_workflow(batch_id)

Inspecting recent deliveries

In the dashboard → Settings → Webhooks → expand the row for any endpoint to see the last 20 delivery attempts: HTTP status returned, response body snippet, attempt count, error. Invaluable for debugging "why isn't my receiver getting these."


FAQ

Why don't message events include the body text? Smaller payloads, no leakage through retries, no PII spread. Hydrate with the message ID if you need the body.

Why is webhook.test delivered even when I didn't subscribe to it? Because the Verify button should always work — making customers subscribe to a meta-event to test their endpoint is hostile DX.

Can I have one URL per event type? Not in v1. One URL per endpoint, subscribe to the events you want, route inside your handler. Multi-URL routing is on the v1.1 roadmap if customers ask.

Can I replay an event? Not via the API in v1. If you need a specific delivery resent, email Oria with the BrandRep-Event-Id. Programmatic replay (POST /v1/events/{id}/resend) is v1.1.

Do you sign the response body or only the request? Request body only. The response is yours — sign your own way if you need integrity.