Outgoing webhooks
Status: Implemented (subscribed tier)
Configure in: Settings → Developer → Webhooks
Production app: https://arcos.corgicy.com/app
Management API: /api/v1/webhooks (session cookie, same as the web app)
Related: Public API · MCP · API reference · Design notes
Outgoing webhooks are live. They are not a planned feature. When something happens in your ARC account, ARC POSTs JSON to your HTTPS server.
The public write API (/api/public/v1) does not push events. MCP does not push events. Webhooks are the only native “ARC → the rest of the internet” path.
ARC does not call third-party products for you. It POSTs to your HTTPS URL; you decide what happens next.
Choose this surface when
| You want… | Use |
|---|---|
| A script or device to write water / workouts / sleep into ARC | Public API |
| An assistant to read and write your life graph | MCP |
| ARC to notify your server when a form is submitted, a task completes, etc. | Webhooks (this page) |
Quick start
- Subscribe (webhooks require an active plan).
- Stand up an HTTPS endpoint that accepts
POSTand returns2xxquickly. - In ARC: Settings → Developer → Webhooks → add the URL → select events → create.
- Copy the
whsec_…secret immediately. It is shown once. - Click Test only after you know which events you subscribed to (see Test deliveries).
- Watch delivery history for status codes.
Minimum viable handler: parse JSON, log event + data, return 200. Add signature checks and idempotency before you go to production.
How to receive and use a webhook
ARC POSTs JSON to the HTTPS URL you register. You own the handler: write to a database, enqueue a job, or POST onward to another API you control.
Something happens in ARC (task completed, form submitted, …)
│
▼
ARC POSTs { event, timestamp, data } to your HTTPS URL
│
▼
Your server verifies HMAC, returns 2xx, then uses `data`
1. Register the endpoint
- Stand up an HTTPS
POSThandler. - Settings → Developer → Webhooks → URL + events → create.
- Save
whsec_…(shown once). - Return
2xxquickly (see Delivery contract).
2. Verify, ack, then do work
- Verify
X-ARCOS-Signatureover the raw request body (not a re-serialized object). - Deduplicate on
X-ARCOS-Delivery-Id(retries reuse the same logical event). - Return
2xxbefore slow work. - Switch on
payload.eventand usepayload.data.
3. Example payload (brain_form_submitted)
Public form /brain/f/<slug>, in-app submit, and MCP brain_form_submit all fire this event for the form owner.
POST /your/path
Content-Type: application/json
X-ARCOS-Signature: sha256=<hex>
X-ARCOS-Event: brain_form_submitted
X-ARCOS-Delivery-Id: <uuid>
{
"event": "brain_form_submitted",
"timestamp": "2026-08-18T12:00:00.000Z",
"data": {
"formId": "uuid",
"formName": "Support intake",
"publicSlug": "support-intake",
"databaseId": "uuid",
"recordId": "uuid",
"values": {
"message": "I need help with billing",
"__respondentName": "Ada",
"__quiz_score": 2,
"__quiz_max": 4,
"__quiz_pct": 50
},
"respondentName": "Ada",
"quiz": { "score": 2, "max": 4, "pct": 50 },
"submittedAt": "2026-08-18T12:00:00.000Z"
}
}
Included: field answers (keys match the form field keys), optional __respondentName, quiz score fields when the form is a quiz.
Stripped: __respondentKey, __respondentUserId, __respondentIpHash, __respondentSignals, __respondentAt.
quiz is null for normal forms. For quizzes it is { score, max, pct } even if the public thank-you screen hides the score.
Most other events carry ids and timestamps only. brain_form_submitted includes answers so you can act on them immediately.
4. Example handler (Node, Express)
Use express.raw({ type: 'application/json' }) so HMAC sees the same bytes ARC signed. After a 2xx, this example forwards JSON to an API you run (DOWNSTREAM_URL).
const crypto = require('crypto');
const express = require('express');
const ARCOS_SECRET = process.env.ARCOS_WEBHOOK_SECRET; // whsec_…
const DOWNSTREAM_URL = process.env.DOWNSTREAM_URL; // optional: your own API
const seen = new Set(); // use Redis in production
function verify(rawBody, header, secret) {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(header || '');
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
app.post('/webhooks/arcos', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verify(req.body, req.get('x-arcos-signature'), ARCOS_SECRET)) {
return res.status(401).send('invalid signature');
}
const deliveryId = req.get('x-arcos-delivery-id');
if (seen.has(deliveryId)) return res.status(200).send('duplicate');
seen.add(deliveryId);
const payload = JSON.parse(req.body.toString('utf8'));
res.status(200).send('ok'); // ack first; do slow work after (or from a queue)
if (DOWNSTREAM_URL) {
await fetch(DOWNSTREAM_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
}
});
Map payload.data however you need (email, CRM, chat, spreadsheet). Third-party credentials stay on your server.
Delivery contract
Every event uses the same envelope:
{
"event": "<event_name>",
"timestamp": "2026-08-18T12:00:00.000Z",
"data": { }
}
| Header | Meaning |
|---|---|
Content-Type | application/json |
X-ARCOS-Signature | sha256= + hex HMAC-SHA256 of the raw JSON body |
X-ARCOS-Event | Same as payload.event |
X-ARCOS-Delivery-Id | Unique per delivery attempt record. Use as an idempotency key |
HMAC key: the whsec_… secret shown when you created the webhook. Compute over the exact bytes in the body (JSON.stringify of { event, timestamp, data } as ARC sent it). Do not pretty-print or reorder keys before hashing.
Timeout: ARC waits 10 seconds. Return 2xx inside that window. Do slow work (downstream APIs, email) after you ack, or from a queue.
| Your status | ARC |
|---|---|
2xx | Success. No retry |
4xx (including 401/403) | Failure. No retry |
5xx | Failure. Retry |
| Network error / timeout | Failure. Retry |
Retries: up to 5 attempts with exponential backoff — 1, 2, 4, 8, then 16 minutes. After that the delivery stays failed. Inactive webhooks are not retried.
Response bodies are stored truncated to 1000 characters in delivery history.
Event catalog
Subscribe per webhook. Unknown names are rejected at create/update.
Fired for your user id (the account that owns the task, form, session, …). Form submissions fire for the form owner, even if a stranger filled the public link.
Tasks
| Event | data fields |
|---|---|
task_completed | taskId, title, completedAt |
task_deferred | taskId, title, nextStep, deferredAt |
task_created | taskId, title, createdAt |
Training & activity
| Event | data fields |
|---|---|
training_logged | sessionId, split, loggedAt |
training_updated | sessionId, updatedAt |
checklist_completed | dayId, itemKey, completedAt |
cardio_logged | sessionId, loggedAt |
water_logged | intakeId, amount, loggedAt |
meditation_logged | sessionId, duration, loggedAt |
Day
| Event | data fields |
|---|---|
day_completed | dayId, completedAt |
minimum_mode_toggled | dayId, minimumMode, toggledAt |
Gamification
| Event | data fields |
|---|---|
achievement_unlocked | achievementId, achievementKey, unlockedAt |
level_up | newLevel, leveledUpAt |
streak_updated | streakType, currentStreak, updatedAt |
Social
| Event | data fields |
|---|---|
friend_added | friendId, addedAt |
message_received | messageId, receivedAt |
Brain
| Event | data fields |
|---|---|
brain_form_submitted | formId, formName, publicSlug, databaseId, recordId, values, respondentName, quiz, submittedAt |
Most events carry ids and timestamps only (no emails). brain_form_submitted is the exception: it includes answers so you can fan out to chat/CRM.
Public API writes (water, cardio, …) go through the same services, so they can fire the matching webhooks (water_logged, cardio_logged, training_logged, meditation_logged) when those services trigger events.
Management API
Requires a logged-in session (cookie) and the webhooks subscription feature. This is the same API the Settings UI uses. Public API keys (arc_…) cannot manage webhooks.
| Method | Path | Body / notes |
|---|---|---|
GET | /api/v1/webhooks | List endpoints + delivery stats (total, success, failed, pending) |
POST | /api/v1/webhooks | { "url": "https://…", "events": ["brain_form_submitted"] } → 201 with secret once |
PUT | /api/v1/webhooks/:webhookId | { "url"?, "events"?, "active"? } |
DELETE | /api/v1/webhooks/:webhookId | Permanent |
GET | /api/v1/webhooks/:webhookId/deliveries | Query: status, event, limit, page |
POST | /api/v1/webhooks/:webhookId/test | { "event": "brain_form_submitted" } optional |
URL must be https://. HTTP is rejected.
Create response (secret only here):
{
"ok": true,
"data": {
"id": "uuid",
"url": "https://example.com/webhooks/arcos",
"events": ["brain_form_submitted"],
"secret": "whsec_…",
"secretPrefix": "whsec_Ab",
"active": true,
"createdAt": "2026-08-18T12:00:00.000Z"
}
}
403 SUBSCRIPTION_REQUIRED if the plan does not include webhooks.
Test deliveries
POST /api/v1/webhooks/:id/test with { "event": "<name>" } and dummy data:
{ "message": "This is a test webhook event", "timestamp": "…" }
Delivery is sent only if that event is in the webhook’s events list. Default event if omitted is test, which is not a real event type — pass a subscribed name (for example brain_form_submitted).
Settings UI
Settings → Developer (not a separate “Webhooks” page).
- Create URL + multi-select events
- Pause (
active) - Delete
- Delivery history (status, status code, truncated body, attempts)
- Copy secret on create only
In-app docs list the same event groups: Task, Training, Activity, Day, Gamification, Social, Brain.
Hardening checklist
- HTTPS endpoint with a valid certificate
- Verify HMAC with a constant-time compare
- Idempotency on
X-ARCOS-Delivery-Id(retries will repeat the same logical event) - Ack
2xxbefore calling slow third parties - Never log the full
whsec_…in plaintext in shared logs - Rotate by creating a new webhook, switching the receiver, then deleting the old one (there is no rotate-secret endpoint)
Troubleshooting
| Symptom | What to check |
|---|---|
SUBSCRIPTION_REQUIRED | Active subscribed plan |
| Create fails “must use HTTPS” | https:// URL, not http:// |
| Create fails “Invalid event type” | Name must match the catalog exactly |
| Nothing arrives | Webhook active, event selected, look at delivery history |
failed with timeout | Handler exceeded 10s; ack first |
failed 4xx | Fix the handler; ARC will not retry 4xx |
| Signature check fails | Hash the raw body, prefix sha256=, use the secret from create |
| Downstream never updates | ARC only POSTs to your URL; debug your handler’s outbound calls |
| Form submit, no webhook | Confirm brain_form_submitted is selected; public form must be published |
What webhooks are not
- Not a read API
- Not MCP
- Not a native connector to chat, CRM, or email products (you POST onward yourself)
- Not the payment-provider webhook (
POST /api/v1/subscription/webhookis inbound Stripe/mock billing, unrelated)