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
| Event | When it fires |
|---|---|
batch.started | The operator worker picks up the batch (queued → running) |
batch.completed | Batch reaches any terminal state (completed / failed / cancelled) |
message.sent | An outbound DM is confirmed delivered by the platform (not when queued) |
message.failed | An outbound DM fails at the platform |
message.replied | An 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
- In the BrandRep dashboard → Settings → Webhooks → Add endpoint.
- Enter your HTTPS URL (must start with
https://— plaintext is rejected). - Pick events to subscribe to. "All events" is fine for v1 — you can ignore the ones you don't handle.
- 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.
- Click Verify on the endpoint row. BrandRep fires a
webhook.testevent 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:
| Method | Path | What it does |
|---|---|---|
GET | /v1/brands/{brand_id}/webhook_endpoints | List all endpoints for the brand |
POST | /v1/brands/{brand_id}/webhook_endpoints | Create 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-secret | Rotate the signing secret |
POST | /v1/webhook_endpoints/{id}/test | Fire 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:
| Value | Behaviour |
|---|---|
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 */ }
}
| Field | Meaning |
|---|---|
id | Stable per logical event. Dedupe on this. Same event delivered twice has the same id. |
type | Event type string. |
created_at | When BrandRep enqueued the delivery. |
occurred_at | When the underlying state change happened. Use this for temporal logic — ordering is not guaranteed. |
brand_id | Which brand this event belongs to. |
data | Event-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-Idis the dedupe key. Same across retries.BrandRep-Delivery-Idis different on each retry attempt — useful in your logs to distinguish attempts.BrandRep-Attemptis 1-indexed.
Verifying the signature
The BrandRep-Signature header has two parts:
t=<unix-timestamp>,v1=<hex-hmac-sha256>
To verify:
- Reject if
|now - t| > 300seconds (5-minute replay window). - Compute
hmac_sha256(secret, f"{t}.".encode() + raw_request_body). - Compare in constant time against the provided
v1hex 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:
| Attempt | Backoff before |
|---|---|
| 1 | 0s (immediate) |
| 2 | 30s |
| 3 | 5 min |
| 4 | 30 min |
| 5 | 2 hours |
| 6 | 5 hours |
| 7 | 12 hours |
| 8 | 24 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"
| Response | What we do |
|---|---|
| 2xx | succeeded — done |
| 4xx (except 408, 429) | failed immediately — receiver's bug, no retries |
| 408 / 429 / 5xx / network error / timeout | Retry on the backoff schedule |
| No response within 10s | Treated 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:
| Field | Type | Notes |
|---|---|---|
campaign_pipeline_id | UUID | BrandRep lead (campaign_pipeline row) ID |
message_id | UUID | BrandRep message ID |
campaign_id | UUID | Which campaign this lead belongs to |
campaign_external_ref | string | null | Your ID for the campaign; set via PATCH /v1/campaigns/{id} |
lead_external_ref | string | null | Your ID for this lead; set via PATCH /v1/campaigns/{id}/leads/external_refs |
social_account_id | UUID | null | The burner account that sent this message |
social_account_handle | string | null | Human-readable handle of the sending account |
sent_at | ISO 8601 string | When 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:
| Field | Type | Notes |
|---|---|---|
failure_reason | string | null | Why 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.