Webhooks
A webhook pushes events to your system the moment they happen — the push counterpart to polling a loop's status. VocaLoop has one webhook per account, configured on Settings → Webhooks. It can subscribe to any event in the system, across all your projects plus account-level actions, and the Submission received event carries the professional's answers and analytics inside the payload — so a receiver can record every response without calling back into the API.
1. Add the webhook
Open Settings → Webhooks (the account menu in the sidebar).
Enter the URL VocaLoop should POST to. It must be
http(s)://; use HTTPS in production.Under Subscribed events, tick what you want to receive. Tick a whole category, or individual events. To record responses, tick Submission received under Requests & submissions.
Click Save. The card shows an Active badge once a URL is stored.
Clear the URL and save to switch the webhook off; your event selection is kept for when you turn it back on.
There is no per-project webhook — one URL receives every project's events. Route inside your receiver on project_id (or project_name) if different projects need different handling.
2. Test it
Send sample POSTs a realistic Submission received payload (flagged
"test": true) to the saved URL and shows you the HTTP status your endpoint answered. Anything other than 2xx is reported as an error.No endpoint yet? Point the URL at the built-in echo sink
http://localhost:3000/api/v1/webhook-echo(dev only — it logs each delivery to the backend console and answers{"received": true}), or use a service like webhook.site.Submitting a project's preview in the builder fires a real
submission.receivedevent with"channel": "preview", so you can test end to end without messaging anyone.
3. What you receive
Every delivery is a POST with Content-Type: application/json and this envelope:
| Field | Meaning |
|---|---|
event | The dotted event name shown next to each checkbox — submission.received, request.sent, project.created, account.signed_in, … |
actor | Who caused it: user (someone signed in), api (an API key), or recipient (the professional filling a form) |
detail | A human-readable one-liner, the same text as on the Logs page |
project_id, project_name | The project the event belongs to; null for account-level events |
context | Event-specific extras: ids, channel names, … |
occurred_at | ISO-8601 timestamp (UTC) |
test | Present and true only on the Send sample delivery |
Submission received — the event that carries data
submission.received adds a submission block with the whole response:
{
"event": "submission.received",
"actor": "recipient",
"detail": "Submission received via whatsapp",
"project_id": "1f0e6c3a-9b2d-4f7e-8a51-c3d2e1f0a9b8",
"project_name": "Lead Capture",
"context": { "submission_id": "9be4c1e2-…", "channel": "whatsapp" },
"occurred_at": "2026-09-09T09:14:31+00:00",
"submission": {
"request_id": "9be4c1e2-…",
"status": "submitted",
"channel": "whatsapp",
"recipient": "+14155550100",
"data": { "full_name": "Ravi Kumar", "email": "ravi@example.com", "company_size": "50-200" },
"analytics": {
"ip": "203.0.113.7",
"user_agent": "Mozilla/5.0 (iPhone; …)",
"accept_language": "en-IN,en;q=0.9",
"referer": null,
"client": { "timezone": "Asia/Kolkata", "locale": "en-IN", "screen": "390x844", "time_to_submit_ms": 84210 }
},
"is_test": false,
"submitted_at": "2026-09-09T09:14:31+00:00"
}
}submission. field | Meaning |
|---|---|
request_id | The loop's id — the same request_id that POST /api/external/loop/send-secure-link returned, and the id you'd poll with GET /api/external/loop/{id}. For a fill link created on the Responses page it is that link's request id |
status | Always submitted on this event |
channel | How the response came in: whatsapp (a loop), link (a single-use link created on the Responses page), or preview (a builder test) |
recipient | The phone the loop was sent to; null for Responses-page links and preview submissions |
data | The answers, keyed by the form's field names. File fields hold the upload metadata (key, filename, content_type, size_bytes) |
analytics | Capture context: IP, user agent, language, referer, and the browser's client block (timezone, locale, screen, time-to-submit) |
is_test | true when the loop was a Send a test from the app |
submitted_at | When the professional submitted (UTC) |
Every other event
The same envelope without a submission block. For example, the loop being sent in the first place:
{
"event": "request.sent",
"actor": "api",
"detail": "Generated project sent on WhatsApp to +14155550100",
"project_id": "1f0e6c3a-9b2d-4f7e-8a51-c3d2e1f0a9b8",
"project_name": "Lead Capture",
"context": { "request_id": "9be4c1e2-…", "api_key_id": "…" },
"occurred_at": "2026-09-09T09:12:01+00:00"
}The full list of events, grouped by category, is the checklist on the Webhooks settings page (also served at GET /api/v1/webhook-events). The categories are: Account, Projects, Requests & submissions, Templates, API keys, Voice vendor setup, and Channels & billing.
4. Record the data
A receiver that stores responses only needs to handle one event. The pattern:
Answer fast. Return
2xxas soon as the payload is parsed and queued; do the real work asynchronously. Deliveries time out after 10 seconds.Filter on
event. Ignore anything that isn'tsubmission.received(or subscribe to that event alone).Skip samples and tests when
testis true orsubmission.is_testis true, unless you want them in a staging table.Use
submission.request_idas the record key. Store on first sight and treat a repeat as a no-op — that keeps a retry or a manual re-send from creating duplicates.Map
submission.datato your fields by name. The keys are the form's field names, so the mapping is stable for a given project; branch onproject_idwhen different projects map differently.Keep
submission.analyticsalongside if you want capture context (where and when it was filled, how long it took).
A minimal receiver in Python (FastAPI):
from fastapi import FastAPI, Request
app = FastAPI()
@app.post("/hooks/vocaloop")
async def vocaloop_hook(request: Request):
hook = await request.json()
if hook.get("event") != "submission.received" or hook.get("test"):
return {"ok": True}
sub = hook["submission"]
if sub["is_test"]:
return {"ok": True}
await responses.upsert( # your storage — idempotent on request_id
key=sub["request_id"],
project_id=hook["project_id"],
recipient=sub["recipient"],
answers=sub["data"],
analytics=sub["analytics"],
submitted_at=sub["submitted_at"],
)
return {"ok": True}The same in Node (Express):
app.post("/hooks/vocaloop", express.json(), async (req, res) => {
const hook = req.body;
res.sendStatus(200); // acknowledge first, work after
if (hook.event !== "submission.received" || hook.test) return;
const sub = hook.submission;
if (sub.is_test) return;
await responses.upsert({ key: sub.request_id, projectId: hook.project_id,
recipient: sub.recipient, answers: sub.data, analytics: sub.analytics,
submittedAt: sub.submitted_at });
});If you also want to know when a loop went out (to show "waiting for a reply" in your own system), subscribe to Request sent and Request failed as well and key them on context.request_id — the same id the submission will carry later.
5. Delivery semantics
Best-effort, one attempt — there are no retries. A failure (non-2xx, timeout, unreachable host) is logged on the backend and never blocks or fails the action itself.
10-second timeout — respond quickly; accept the payload and process it asynchronously if your handling is slow.
After commit only — events are delivered once the action has actually been committed, so you never hear about something that was rolled back. Delivery starts right after the response to the action is sent.
Ordering is not guaranteed across events; use
occurred_atand the ids in the payload rather than arrival order.Because a delivery can be missed, treat the webhook as the fast path and
GET /api/external/loop/{request_id}on the Secure Link Loop API as the source of truth for anything critical — it returns the samedataandanalytics.
6. Securing your endpoint
Payloads are not signed, so:
Use an unguessable URL path (it acts as a shared secret) and always HTTPS in production.
Validate that
project_idis one of your projects, and treatsubmission.dataas untrusted user input.Never act on a payload you can't reconcile: if in doubt, read the loop back with the API-key-authenticated
GET /api/external/loop/{request_id}, which can't be spoofed.
Troubleshooting
| Symptom | What to check |
|---|---|
| Send sample reports an HTTP error | Your endpoint answered non-2xx or didn't answer within 10 s. The status code is shown in the error |
| Nothing arrives for real submissions | The event isn't ticked (Submission received under Requests & submissions), or the URL was cleared. The Logs page still shows every event, delivered or not |
Deliveries arrive but submission is missing | You're looking at a different event — only submission.received carries the block |
The same request_id arrives twice | A preview re-submit or a manual test; upsert on request_id |