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.
- Copy the signing secret. You can see it again later with Show signing secret.
- 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.
In n8n, use a Webhook node; in Make, a Custom webhook; in Zapier, Catch Hook. Paste the address they give you.
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" }
]
}
| 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.
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.