formtoDashboard

Send responses somewhere

Every form emails the workspace owner. On paid plans a form can also send each response to a webhook, Slack, Discord or Zapier. Add destinations in the dashboard under a form's Connections, or with the API.

Add a webhook

sh
FORM_ID=$(curl -fsS https://api.formto.io/v2/forms \
  -H "Authorization: Bearer $FORMTO_TOKEN" -H 'content-type: application/json' \
  -d '{"name":"Webhook demo"}' | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).id')
curl -fsS "https://api.formto.io/v2/forms/$FORM_ID/destinations" \
  -H "Authorization: Bearer $FORMTO_TOKEN" -H 'content-type: application/json' \
  -d '{"kind":"webhook","config":{"url":"https://example.com/formto-webhook"}}'

The answer includes the signing secret once. Store it with your receiver; formto never shows it again. Webhook URLs must be public HTTPS hosts: private addresses and IP literals are refused.

Verify a webhook

Deliveries follow Standard Webhooks: headers webhook-id, webhook-timestamp and webhook-signature (v1,<base64 HMAC-SHA256>). Verify before trusting the body:

js
import { createHmac, timingSafeEqual } from 'node:crypto'

function verify(secret, headers, body) {
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
  const signed = `${headers['webhook-id']}.${headers['webhook-timestamp']}.${body}`
  const expected = createHmac('sha256', key).update(signed).digest('base64')
  const fresh = Math.abs(Date.now() / 1000 - Number(headers['webhook-timestamp'])) < 300
  return fresh && headers['webhook-signature'].split(' ').some((sig) => {
    const value = sig.split(',')[1] ?? ''
    return value.length === expected.length && timingSafeEqual(Buffer.from(value), Buffer.from(expected))
  })
}

// A self-check with a made-up secret, so this file runs as is.
const secret = 'whsec_' + Buffer.from('formto-docs-example-secret-32byte').toString('base64')
const body = JSON.stringify({ event: 'submission.created' })
const headers = { 'webhook-id': 'msg_1', 'webhook-timestamp': String(Math.floor(Date.now() / 1000)) }
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
headers['webhook-signature'] = 'v1,' + createHmac('sha256', key).update(`${headers['webhook-id']}.${headers['webhook-timestamp']}.${body}`).digest('base64')
if (!verify(secret, headers, body)) throw new Error('verification failed')
console.log('signature ok')

Receivers that dedupe on webhook-id ignore a replay of a delivery that already succeeded. Failed deliveries are retried with backoff, and every attempt is in the dashboard under Logs, where you can retry by hand.