Typeform Integration
Receive and route Typeform webhooks for form responses.
Setup
1. Create a Source in Hookbase
curl -X POST https://api.hookbase.app/api/sources \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Typeform Production",
"slug": "typeform",
"provider": "typeform",
"signingSecret": "your-generated-secret"
}'Typeform does not issue a secret — you supply one. When you create the source in the dashboard,
Hookbase generates the Secret for you; over the API, generate a random string yourself (for
example openssl rand -hex 32) and pass it as signingSecret.
Save your webhook URL:
https://api.hookbase.app/ingest/{orgSlug}/typeform2. Create the Typeform Webhook
Typeform webhooks are configured per form, in that form's Connect → Webhooks panel.
- Add a webhook and set its endpoint to your Hookbase ingest URL
- Click Edit on the webhook, paste the Hookbase-generated secret into Secret, and click Save changes
- Turn the webhook on
The secret comes from your side, not from Typeform, so the value in Typeform's Secret field and the
signingSecret on your Hookbase source must match exactly. A webhook left without a secret sends no Typeform-Signature header at all, and a
source that has a signingSecret configured treats a missing signature header as a failed
verification rather than as "nothing to check".
Each form has its own webhook and its own secret. Routing two forms into one Hookbase source means only the form whose secret you stored will verify — use one source per form, or set the same secret on both webhooks.
See Typeform's webhook security guide for the signature scheme.
3. Create Destinations and Routes
# Create a destination
curl -X POST https://api.hookbase.app/api/destinations \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{"name": "Response Store", "slug": "typeform-responses", "url": "https://api.myapp.com/webhooks/typeform"}'
# Create a route
curl -X POST https://api.hookbase.app/api/routes \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{"name": "Typeform to Response Store", "sourceId": "src_...", "destinationId": "dst_..."}'A route carries one destination. To fan the same responses out to two services, create two routes over the same source.
Signature Verification
Typeform signs the raw request body with HMAC-SHA256 and sends the base64 digest in the
Typeform-Signature header behind a sha256= prefix:
Typeform-Signature: sha256=5FvJ9hOu/uBUWdR/v8EVsqM9TOpvrVo5g7dcyopOMkw=Hookbase compares the header against its own base64 digest of the body with the prefix removed. The
prefix is required: a bare digest in Typeform-Signature is treated as malformed rather than compared.
The secret is used exactly as you stored it — its UTF-8 bytes are the HMAC key. Nothing is stripped from it and nothing is base64-decoded first; only the digest is base64, not the key.
Hookbase verifies this automatically once the source has both fields set:
{
"provider": "typeform",
"signingSecret": "your-generated-secret"
}Warning
The sha256= prefix here carries base64, not hex. GitHub's X-Hub-Signature-256 uses a prefix
that looks identical and carries a 64-character hex digest. Typeform's carries a 44-character base64
digest of the same 32 bytes, ending in =.
This is the detail that breaks manual verification. If you reproduce the signature the way you would
for GitHub — hmac.hexdigest(), or -hex on the command line — you get a well-formed value that can
never match, and the mismatch looks like a wrong secret. Take the digest as bytes and base64-encode
it instead: in Python, base64 over hmac.new(secret, body, sha256).digest(); on the command line,
openssl dgst -sha256 -hmac "$SECRET" -binary | base64.
There is nothing to configure for this. Hookbase already encodes Typeform's digest as base64 — the warning is for your own side of the comparison.
Info
Sign the raw body, not re-serialized JSON. The digest covers the exact bytes Typeform sent. Parsing the body and re-encoding it changes key order and whitespace, and the digest changes with it. Hookbase verifies against the raw body it received, before any transform runs.
Once you have confirmed events are arriving with signature_valid: true, set
rejectInvalidSignatures: true on the source to have unverified requests refused with 401 instead
of stored.
Common Events
| Event | Description |
|---|---|
form_response | A form response was submitted |
Typeform's webhooks are response-centric: the event type is in event_type, and the submission itself
is in form_response.
Form Response
{
"event_id": "evt-abc123",
"event_type": "form_response",
"form_response": {
"form_id": "frm_def456",
"token": "resp-ghi789",
"submitted_at": "2024-01-15T10:30:00Z",
"landed_at": "2024-01-15T10:28:00Z",
"definition": {
"id": "frm_def456",
"title": "Customer Feedback Survey",
"fields": [
{ "id": "fld_001", "title": "How satisfied are you?", "type": "opinion_scale" },
{ "id": "fld_002", "title": "Any additional comments?", "type": "long_text" }
]
},
"answers": [
{ "field": { "id": "fld_001", "type": "opinion_scale" }, "type": "number", "number": 9 },
{ "field": { "id": "fld_002", "type": "long_text" }, "type": "text", "text": "Great product, love the new features!" }
]
}
}Transform Examples
A javascript transform receives the parsed payload and returns the body Hookbase delivers.
Flatten Answers into One Object
Each entry in answers names its value under a key matching its own type, so there is no single
field to read. This picks the right one per answer and keys the result by field id.
function transform(payload) {
const r = payload.form_response || {};
const titles = {};
((r.definition && r.definition.fields) || []).forEach(f => { titles[f.id] = f.title; });
const answers = {};
(r.answers || []).forEach(a => {
const id = a.field ? a.field.id : null;
if (!id) return;
// The value key is named after the answer's type: number, text, email, boolean, and so on.
// choice/choices are objects, so keep them whole rather than guessing a label.
const value = a[a.type];
answers[id] = { question: titles[id] || null, value: value === undefined ? null : value };
});
return {
event_id: payload.event_id,
form_id: r.form_id || null,
response_token: r.token || null,
submitted_at: r.submitted_at || null,
seconds_to_complete: r.landed_at && r.submitted_at
? Math.round((new Date(r.submitted_at) - new Date(r.landed_at)) / 1000)
: null,
answers: answers
};
}Slack Notification with the First Answer
function transform(payload) {
const r = payload.form_response || {};
const title = (r.definition && r.definition.title) || r.form_id || "form";
const first = (r.answers || [])[0];
const firstValue = first ? first[first.type] : null;
return {
text: `New response: ${title}`,
blocks: [
{
type: "section",
fields: [
{ type: "mrkdwn", text: `*Form:*\n${title}` },
{ type: "mrkdwn", text: `*Submitted:*\n${r.submitted_at || "unknown"}` },
{ type: "mrkdwn", text: `*Answers:*\n${(r.answers || []).length}` },
{ type: "mrkdwn", text: `*First answer:*\n${firstValue === null ? "none" : String(firstValue)}` }
]
}
]
};
}Filter Examples
Filter conditions read dotted paths out of the payload. logic must be AND or OR.
One Form Only
{
"name": "Feedback Survey Only",
"logic": "AND",
"conditions": [
{
"field": "form_response.form_id",
"operator": "equals",
"value": "frm_def456"
}
]
}Low Scores Only
Positional paths depend on the order of answers, which follows the form's field order. This reads
the first answer, so it holds only while that field stays first.
{
"name": "Detractors",
"logic": "AND",
"conditions": [
{
"field": "form_response.answers.0.number",
"operator": "less_than",
"value": 7
}
]
}Completed Responses Only
{
"name": "Submitted",
"logic": "AND",
"conditions": [
{
"field": "form_response.submitted_at",
"operator": "exists",
"value": ""
}
]
}Headers
| Header | Description |
|---|---|
Typeform-Signature | sha256= followed by the base64 HMAC-SHA256 of the raw body |
Hookbase does not forward these headers to your destination. A delivery is a fresh request:
Hookbase sets Content-Type, User-Agent: Hookbase/1.0, X-Delivery-ID and X-Event-ID, then
adds the headers and auth you configured on the destination. Nothing Typeform sent reaches your
handler as a header — read what you need out of the body, or set it on the destination yourself.
Of the incoming headers, only content-type, user-agent, x-github-event, x-gitlab-event and
stripe-signature are stored on the event, so those are the only ones visible later in the
dashboard or the API.
Troubleshooting
Signature Verification Failed
- Confirm you are comparing base64, not hex. See the warning above — this is the most common cause, and it looks exactly like a wrong secret
- Confirm the source's
provideristypeform. A source left oncustomreadsX-Signature,X-Webhook-SignatureandX-Hub-Signature-256and expects hex, so Typeform's header is never even read - Confirm the value in the Typeform webhook's Secret field and the
signingSecreton the source are the same string. Because the value is copied by hand, a typo or stray whitespace in either place is possible and produces no other symptom - Confirm the form actually has a secret set. Without one, Typeform sends no signature header, and a
source with a
signingSecretrecords that as unverified
No Events Arriving
- Check the webhook is toggled on for that form
- Confirm the endpoint matches your ingest URL exactly, including the org and source slugs
- Typeform sends on submission. A respondent who lands on the form without finishing it produces no webhook
Duplicate Events
Typeform puts an event_id on every delivery and a token on every response. On a source with
dedupEnabled: true, the default auto strategy dedups Typeform traffic on the payload's event_id:
two deliveries carrying the same event_id inside the dedup window collapse into one. A payload
without event_id falls back to hashing the body. In your handler, use form_response.token as the
idempotency key: it identifies the response, so a retry and a genuine resubmission are
distinguishable.