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-typeform-webhook-secret"
}'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
- Set the webhook's secret, and store the same value on your Hookbase source as
signingSecret - Turn the webhook on
The secret is one you choose, not one Typeform generates, so it has to be typed into both places and
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-typeform-webhook-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 secret on the Typeform webhook and the
signingSecreton the source are the same string. Because you choose this value, a typo 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. Hookbase has no
provider event-id extractor for Typeform, so the default auto dedup strategy falls back to hashing
the payload — which is close to equivalent here, because both ids are part of the body. Use
form_response.token in your handler as the idempotency key: it identifies the response, so a retry
and a genuine resubmission are distinguishable.