Skip to main content

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 ARCPublic API
An assistant to read and write your life graphMCP
ARC to notify your server when a form is submitted, a task completes, etc.Webhooks (this page)

Quick start​

  1. Subscribe (webhooks require an active plan).
  2. Stand up an HTTPS endpoint that accepts POST and returns 2xx quickly.
  3. In ARC: Settings → Developer → Webhooks → add the URL → select events → create.
  4. Copy the whsec_… secret immediately. It is shown once.
  5. Click Test only after you know which events you subscribed to (see Test deliveries).
  6. 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​

  1. Stand up an HTTPS POST handler.
  2. Settings → Developer → Webhooks → URL + events → create.
  3. Save whsec_… (shown once).
  4. Return 2xx quickly (see Delivery contract).

2. Verify, ack, then do work​

  1. Verify X-ARCOS-Signature over the raw request body (not a re-serialized object).
  2. Deduplicate on X-ARCOS-Delivery-Id (retries reuse the same logical event).
  3. Return 2xx before slow work.
  4. Switch on payload.event and use payload.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": { }
}
HeaderMeaning
Content-Typeapplication/json
X-ARCOS-Signaturesha256= + hex HMAC-SHA256 of the raw JSON body
X-ARCOS-EventSame as payload.event
X-ARCOS-Delivery-IdUnique 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 statusARC
2xxSuccess. No retry
4xx (including 401/403)Failure. No retry
5xxFailure. Retry
Network error / timeoutFailure. 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​

Eventdata fields
task_completedtaskId, title, completedAt
task_deferredtaskId, title, nextStep, deferredAt
task_createdtaskId, title, createdAt

Training & activity​

Eventdata fields
training_loggedsessionId, split, loggedAt
training_updatedsessionId, updatedAt
checklist_completeddayId, itemKey, completedAt
cardio_loggedsessionId, loggedAt
water_loggedintakeId, amount, loggedAt
meditation_loggedsessionId, duration, loggedAt

Day​

Eventdata fields
day_completeddayId, completedAt
minimum_mode_toggleddayId, minimumMode, toggledAt

Gamification​

Eventdata fields
achievement_unlockedachievementId, achievementKey, unlockedAt
level_upnewLevel, leveledUpAt
streak_updatedstreakType, currentStreak, updatedAt

Social​

Eventdata fields
friend_addedfriendId, addedAt
message_receivedmessageId, receivedAt

Brain​

Eventdata fields
brain_form_submittedformId, 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.

MethodPathBody / notes
GET/api/v1/webhooksList 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/:webhookIdPermanent
GET/api/v1/webhooks/:webhookId/deliveriesQuery: 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 2xx before 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​

SymptomWhat to check
SUBSCRIPTION_REQUIREDActive subscribed plan
Create fails “must use HTTPS”https:// URL, not http://
Create fails “Invalid event type”Name must match the catalog exactly
Nothing arrivesWebhook active, event selected, look at delivery history
failed with timeoutHandler exceeded 10s; ack first
failed 4xxFix the handler; ARC will not retry 4xx
Signature check failsHash the raw body, prefix sha256=, use the secret from create
Downstream never updatesARC only POSTs to your URL; debug your handler’s outbound calls
Form submit, no webhookConfirm 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/webhook is inbound Stripe/mock billing, unrelated)