Send answers to your own system (webhooks)
For every response, Agent002 sends an HTTPS POST with the answers as JSON to an address you choose. It works with your own code and with tools like n8n, Make or Zapier.
Set it up
- Open your form and go to Destinations, then choose Webhook and paste the address (or pick one you already use). Addresses are saved on the Connections page, so other forms can use them too.
- Copy the signing secret. You can see it again later with Show signing secret, on the form or on the Connections page.
- Once the form is published, use Send a test. The test has
"event": "submission.test".
The address must use https:// on the standard port (443) or 8443, and point to a public server. Redirects aren’t followed, so use the final address.
Step-by-step guides: n8n, Make, Zapier.
What we send
{
"event": "submission.created",
"form": { "id": "6f0c…", "name": "Event RSVP", "slug": "k7m2p9qrst" },
"version": 3,
"submission": {
"id": "b21e…",
"createdAt": "2026-10-19T09:41:07.52+00:00",
"answers": {
"name": "Ana Popescu",
"email": "ana@example.com",
"attending": true,
"guests": 1,
"dietary": ["vegetarian"],
"consent": true
}
},
"questions": [
{ "key": "name", "label": "Your name", "type": "short_text" },
{ "key": "attending", "label": "Will you be there?", "type": "yes_no" }
],
"fields": [
{ "key": "name", "label": "Your name", "type": "short_text", "value": "Ana Popescu", "text": "Ana Popescu" },
{ "key": "attending", "label": "Will you be there?", "type": "yes_no", "value": true, "text": "Yes" },
{ "key": "dietary", "label": "Dietary needs", "type": "multi_choice", "value": ["vegetarian", "vegan"], "text": "vegetarian; vegan" }
]
}
| Question type | Answer in JSON |
|---|---|
| Short and long answer, email, phone | Text |
| Number | Number |
| Date | Text, YYYY-MM-DD |
| One choice | The option’s value |
| Multiple choice | A list of option values |
| Yes / No, consent | true or false |
Questions that were hidden by a condition, or left empty, are not included. questions lists every question in the published version, so you can show labels instead of keys. fields has every answered question in form order, with its label, its value as above and a text version (several choices joined with semicolons, yes/no as Yes or No): the easiest one to map in no-code tools.
Send a test on the Connections page sends "event": "connection.test" with no form or answers, just to check the address and the signature.
Headers
| Header | Meaning |
|---|---|
X-Agent002-Event | submission.created, or submission.test for tests |
X-Agent002-Delivery | The same ID on every retry of the same delivery. Use it to ignore duplicates. |
X-Agent002-Timestamp | When we sent it, in Unix seconds |
X-Agent002-Signature | sha256= followed by the HMAC-SHA256 of timestamp + "." + body, using your signing secret, in lowercase hex |
Check it came from us
Anyone who knows your address could send it data. Checking the signature proves a request came from Agent002 and wasn’t changed on the way. Always use the raw body exactly as received.
Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';
const SECRET = process.env.AGENT002_SECRET; // the webhook's signing secret
function verify(rawBody, headers) {
const ts = headers['x-agent002-timestamp'];
const sig = headers['x-agent002-signature'] ?? '';
// Reject old or missing timestamps, so a captured request can't be replayed later.
if (!/^\d+$/.test(ts ?? '') || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(`${ts}.`).update(rawBody).digest('hex');
const a = Buffer.from(sig), b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
// Use the raw body: re-serialized JSON won't match the signature.
app.post('/agent002', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req.body, req.headers)) return res.sendStatus(401);
const event = JSON.parse(req.body);
// Use X-Agent002-Delivery to ignore a delivery you've already processed.
console.log(event.form.name, event.submission.answers);
res.sendStatus(204);
});
app.listen(3000);
Python (Flask)
import hashlib, hmac, os, re, time
from flask import Flask, abort, request
SECRET = os.environ["AGENT002_SECRET"] # the webhook's signing secret
app = Flask(__name__)
def verify(raw_body: bytes, headers) -> bool:
ts = headers.get("X-Agent002-Timestamp", "")
sig = headers.get("X-Agent002-Signature", "")
if not re.fullmatch(r"[0-9]+", ts) or abs(time.time() - int(ts)) > 300:
return False
expected = "sha256=" + hmac.new(SECRET.encode(), ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(sig.encode(), expected.encode())
@app.post("/agent002")
def agent002():
if not verify(request.get_data(), request.headers):
abort(401)
event = request.get_json()
print(event["form"]["name"], event["submission"]["answers"])
return "", 204
Answer quickly, we retry the rest
- Reply with any
2xxstatus within 10 seconds. Do slow work after replying. - If your server is down, times out, or answers
5xx,408or429, we try again after 1 minute, then 5 and 30 minutes, 2 hours and 12 hours. - Other
4xxanswers (like a wrong address or401) aren’t retried, since they won’t fix themselves. - The Destinations tab shows every delivery with the result, and Retry now sends a failed one again.