Dispatches
Configure the Monday brief in Competitor Tracker & Co.: choose recipients, scope a dispatch with labels, add webhooks and use the thumbs feedback links.
Every Monday a pack of agents files what it found. A brief is the delivery side of that report: who hears about it and how. Your guide here is our lead detective C. T. Lucky. For parameter-level detail, see the API reference in the sidebar.
Your organization dispatches the brief over two channels: email to a list of recipients and webhook to your own endpoints. An email dispatch is one brief: a name, an optional set of labels that scope it and a list of recipients. Members can read both. Changing either is admin-only.
The default brief
Every organization keeps one default notification. You don't create it; it's there the first time the brief runs. It covers every competitor you're tailing and goes to every active member of the organization. The owner is always on it, pinned and impossible to take off.
You can rename it, recolor its theme or change recipients. You can't delete it. A DELETE against the default returns 409 CannotDeleteDefaultNotification. One brief always goes out.
Custom briefs
Beyond the default, an admin can create extra notifications scoped to a set of labels. Useful when one slice of the roster deserves its own audience — direct competitors to the product team, adjacent tools to whoever watches the wider market.
POST /v1/dispatches
# Name it, scope it to a few labels. Admin-only.
curl -X POST "$CT_API/dispatches" \
-H "Authorization: Bearer $CT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "Direct competitors", "labelIds": ["01HF..."] }'Returns 201 Created. The name is 1–64 characters. labelIds is optional — omit it and the brief is scoped to nothing until you add labels later. A custom brief starts with no recipients, so add them before the next Monday or it goes to no one.
A custom notification does not auto-include every member the way the default does. Its audience is exactly the recipients you add.
List
GET /v1/dispatches
Returns every notification for the organization, default first, each with its label scope and current recipients. Members can call this.
curl "$CT_API/dispatches" \
-H "Authorization: Bearer $CT_TOKEN"Each recipient carries a kind (member or external), a confirmationStatus (confirmed or pending) and an isOwnerPin flag that's true only for the pinned owner on the default brief.
Update
PATCH /v1/dispatches/{id}
Rename, change the theme or rescope. Every field is optional but send at least one.
name— 1–64 characters.labelIds— replaces the existing scope entirely. Not additive.theme—darkorlight. Picks which rendering the brief email uses.
curl -X PATCH "$CT_API/dispatches/01HF..." \
-H "Authorization: Bearer $CT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "theme": "light" }'Returns 200 OK. 404 NotificationNotFound if no active notification with that id belongs to your organization.
Delete
DELETE /v1/dispatches/{id}
Removes a custom notification and its recipients. Returns 204 No Content. The default can't be removed — see above.
Recipients
A recipient is one mailbox on a brief. Two kinds.
A member recipient is someone already in your organization. Add them and they're on the brief right away, with no confirmation step, since they're already inside.
An external recipient is any other email, like a contractor or a shared inbox you want looped in. We don't put an outside address on a brief on your say-so alone. We email them a confirmation link first. Until they click it they show as pending and receive nothing. Once they confirm they flip to confirmed and start getting the brief. Every brief we send them also carries an unsubscribe link, so they can take themselves off at any time without going through you.
Add a recipient
POST /v1/dispatches/{id}/recipients
# A member joins immediately. An external address gets a confirmation email.
curl -X POST "$CT_API/dispatches/01HF.../recipients" \
-H "Authorization: Bearer $CT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "kind": "external", "email": "analyst@partner.example" }'For kind: "member" send a userId. For kind: "external" send an email. Mismatch the pair and the body fails validation with 400. Returns 201 Created with the new recipient, pending for a fresh external address.
A brief holds at most 5 external recipients. The sixth returns 422 ExternalRecipientCapReached. Members don't count toward that cap.
Remove a recipient
DELETE /v1/dispatches/{id}/recipients/{recipientId}
Returns 204 No Content. The pinned owner on the default brief can't be removed — that attempt returns 409 CannotRemoveOwnerPin. To change who's pinned, transfer ownership.
External recipients can also remove themselves from the unsubscribe link in any brief, no admin needed.
Webhooks
A webhook gets the same Monday brief as machine-readable JSON, posted to a URL you own. Register up to 3 per organization. Members can list them; an admin registers and removes them.
Register
POST /v1/dispatches/webhooks
# The URL must be https — we sign the body and won't send it in the clear.
curl -X POST "$CT_API/dispatches/webhooks" \
-H "Authorization: Bearer $CT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "url": "https://hooks.example.com/competitor-tracker" }'Returns 201 Created. The response carries a secret — the signing key for that webhook. We show it once and never again, so store it when you get it. The fourth webhook returns 422 WebhookCapReached.
Verifying delivery
When the brief runs we POST the JSON body to your URL with one extra header:
x-competitortracker-signature: <hex>The value is HMAC-SHA256 over the exact raw request body, keyed with your webhook's secret, hex-encoded. To trust a delivery, recompute the same HMAC over the bytes you received and compare it against the header in constant time. A match means the body is ours and untampered. Verify before you trust.
Node, for reference:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret) {
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header);
return a.length === b.length && timingSafeEqual(a, b);
}Delivery is best-effort: one attempt per brief, no retry queue. A webhook that's down for a Monday misses that brief — email recipients still get theirs. The webhook body is the shared brief, not personalized per reader, so it carries no per-recipient feedback links.
The payload
The body is one JSON object: an envelope around the brief. type is always weekly_brief. orgId and notificationId tell you which brief filed this report.
{
"type": "weekly_brief",
"orgId": "01HF...",
"notificationId": "01HF...",
"brief": {
"weekNumber": 24,
"dateRange": "Jun 2 – Jun 8",
"layout": "populated",
"summary": "Quiet week on the block. 10 of 18 moved, mostly repositioning.",
"featured": [
{
"competitorId": "01HF...",
"name": "LeaveWizard",
"isHome": false,
"domain": "leavewizard.com",
"faviconUrl": "https://assets.competitortracker.dev/favicons/01HF....png",
"high": 2,
"medium": 1,
"low": 1,
"totalCount": 8,
"remainingCount": 4,
"changes": [
{
"changeId": "01HF...",
"category": "product_changes",
"priority": "high",
"description": "Added shift management with patterns.",
"snapshotId": "01HF..."
}
]
}
],
"alsoMoved": [
{
"competitorId": "01HF...",
"name": "Calamari",
"domain": "calamari.io",
"high": 2,
"medium": 3,
"low": 1
}
],
"quiet": ["PTO Genius", "Flamingo"],
"roster": [],
"watchedCount": 18,
"movedCount": 10,
"labels": [{ "id": "01HF...", "name": "Leave Management", "color": "yellow" }],
"announcement": null,
"totalChanges": 30
}
}The brief object mirrors what the email shows:
| Field | Type | Description |
|---|---|---|
weekNumber | integer | ISO week number of the Monday the brief ran. |
dateRange | string | The window it covers, ready to print. |
layout | string | populated when something moved. zero_state when nobody did. |
summary | string | The week's writeup in Lucky's voice. |
featured | array<competitor> | The movers that earned a full block. Each carries the cleaned name, isHome, domain, a faviconUrl, the high / medium / low counts, totalCount, a remainingCount for the moves that didn't fit and up to 5 changes (each with changeId, category, priority, description and snapshotId). |
alsoMoved | array<competitor> | Movers that didn't make the cut. Name and counts only, no per-change rows. |
quiet | array<string> | Names of the competitors that held still. |
roster | array<string> | On a zero_state brief, everyone still on the beat. Empty otherwise. |
watchedCount | integer | How many competitors this brief watches. |
movedCount | integer | How many of them moved. Never more than watchedCount. |
labels | array<label> | The labels scoping this brief. Empty for the default. |
announcement | object | null | An occasional note from us, or null. |
totalChanges | integer | Every move in the window. |
On a quiet week layout is zero_state, featured and alsoMoved come back empty and roster carries the watch. We may add fields over time, so read the ones you need and let the rest ride. The shape holds.
List
GET /v1/dispatches/webhooks
Returns every webhook for the organization. The secret is never on this response — it was yours to keep from the create call.
Remove
DELETE /v1/dispatches/webhooks/{id}
Returns 204 No Content and frees one of the three slots.
Delivery log
Every Monday send leaves a paper trail. For each webhook we record whether the POST went through; for each recipient we record whether their email landed. Members can read both. We keep the receipts.
The log is history-only. There's nothing to change here, no resend button — just a record of what happened on each send. Every entry carries an attempt number, so when a send is tried more than once the later tries show up as further attempts on the same line of work.
A webhook's deliveries
GET /v1/dispatches/webhooks/{id}/deliveries
The delivery history for one webhook, newest-first. Each entry carries an attempt number, a status, the HTTP statusCode we got back and an attemptedAt timestamp. status is one of delivered, failed, retrying or pending. When a delivery fails the entry also carries a short error; on a clean delivery error and statusCode may be empty.
# What happened to this webhook on past Mondays.
curl "$CT_API/dispatches/webhooks/01HF.../deliveries" \
-H "Authorization: Bearer $CT_TOKEN"Returns 200 OK. The page holds up to 100 entries; pass limit to ask for fewer. When more remain the response carries a nextCursor — feed it back as cursor to read the next page. A null cursor means you've reached the end.
A notification's email deliveries
GET /v1/dispatches/{id}/deliveries
The per-recipient email outcomes for one notification, newest-first. Each entry carries an attempt number, a status, an attemptedAt timestamp and who it was for: recipientEmail and, for a member, recipientUserId. A failed entry carries a short error.
# Who got this brief, and who didn't.
curl "$CT_API/dispatches/01HF.../deliveries" \
-H "Authorization: Bearer $CT_TOKEN"Returns 200 OK. Paging works the same way: limit to size the page, cursor to walk it, a null nextCursor at the end.
Thumbs on a change
Each change in an emailed brief carries two links: a thumbs-up and a thumbs-down. They tell us whether we read that one right. The links work without a login — the brief email already proves who you are, so each thumb carries a single-use token minted for that one reader and that one change.
POST /v1/changes/{id}/feedback
# The token rides in the thumbs link. One use, then it's spent.
curl -X POST "$CT_API/changes/01HF.../feedback" \
-H "Content-Type: application/json" \
-d '{ "token": "01HF...", "vote": "up" }'vote is up or down. An optional comment (≤ 2000 chars) rides along if you want to tell us why. Returns 200 OK. A token that's already spent, expired or never valid returns 404. So does one that doesn't match the change. You won't usually call this by hand; the brief's links do it for you.
Labels
Organize competitors in Competitor Tracker & Co. with labels: create, list, update and delete organization labels, then pin or unpin them on any competitor.
Billing
How billing works in Competitor Tracker & Co.: coins fund each tracked competitor, how and when charges apply and the endpoints that surface your balance.