Export Typeform to PostgreSQL: Webhooks, the Responses API, or a Managed Sync
To export Typeform to PostgreSQL, store each response as one jsonb row keyed on its response ID, and choose how rows arrive. A Typeform webhook can post every submission to a small endpoint that upserts it within seconds. The Responses API can backfill history or run as a nightly batch. Or a managed tool such as Amazon AppFlow, Airbyte, or Fivetran can sync responses on a schedule. Typeform has no built-in Postgres integration, so each route ends in the same table design, and most teams combine two of them: the API for history, the webhook for everything new.
This guide builds that table, wires up both code routes, and shows where the managed options fit. If you only need files, the Export Typeform to JSON guide covers CSV downloads and raw API output. If your warehouse is Redshift rather than Postgres, the Typeform to Redshift guide covers the SUPER column version of this pattern.
The table: one jsonb row per response
Typeform forms change. Someone adds a question, renames one, or reorders the flow, and a table with one column per question breaks on the next load. A jsonb column absorbs those edits, and a view on top gives you columns when you want them.
CREATE TABLE typeform_responses (
response_id text PRIMARY KEY,
form_id text NOT NULL,
submitted_at timestamptz,
source text NOT NULL DEFAULT 'typeform',
payload jsonb NOT NULL,
loaded_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX typeform_responses_form_idx
ON typeform_responses (form_id, submitted_at);
CREATE INDEX typeform_responses_payload_idx
ON typeform_responses USING gin (payload jsonb_path_ops);
The primary key matters more than it looks. Webhooks retry, batch jobs overlap, and both routes can deliver the same response. With response_id as the key, every write can be an INSERT ... ON CONFLICT DO UPDATE, so duplicates are impossible and reruns are harmless. The webhook payload calls this value token; Typeform’s docs state that it is identical to the response ID in the Responses API, which is what lets the two routes share one table.
Route 1: a webhook for new submissions
Typeform’s Webhooks API sends a form_response event to your URL as soon as someone submits. A few rules from the docs shape the receiver:
- HTTPS only. New webhook URLs must use
httpswith a valid certificate. Self-signed certificates are rejected. - Answer within 30 seconds. Slower responses count as failures and trigger retries, so write the row and return. Do heavy processing later.
- Retries depend on your status code. A
404or410disables the webhook immediately. A429,408,423, or503is retried every few minutes for up to 10 hours. Other errors are retried on a back-off schedule over several hours. - No fixed IP range. Typeform runs on AWS with dynamic addresses, so you cannot allowlist IPs. Verify the signature instead.
To sign payloads, set a secret on the webhook through the create or update webhook endpoint. Typeform then sends a Typeform-Signature header containing sha256= followed by a base64 HMAC-SHA256 of the raw body, as described in Secure your webhooks.
Here is a complete receiver in Python with Flask and psycopg 3:
import base64
import hashlib
import hmac
import os
import psycopg
from flask import Flask, abort, request
from psycopg.types.json import Jsonb
SECRET = os.environ["TYPEFORM_WEBHOOK_SECRET"].encode()
DSN = os.environ["DATABASE_URL"]
app = Flask(__name__)
UPSERT = """
INSERT INTO typeform_responses (response_id, form_id, submitted_at, payload)
VALUES (%s, %s, %s, %s)
ON CONFLICT (response_id) DO UPDATE
SET submitted_at = EXCLUDED.submitted_at,
payload = EXCLUDED.payload,
loaded_at = now()
"""
def signature_ok(raw: bytes, header: str | None) -> bool:
if not header:
return False
digest = hmac.new(SECRET, raw, hashlib.sha256).digest()
expected = "sha256=" + base64.b64encode(digest).decode()
return hmac.compare_digest(expected, header)
@app.post("/typeform")
def typeform_webhook():
raw = request.get_data()
if not signature_ok(raw, request.headers.get("Typeform-Signature")):
abort(403)
event = request.get_json()
if event.get("event_type") != "form_response":
return "", 204
fr = event["form_response"]
with psycopg.connect(DSN) as conn:
conn.execute(UPSERT, (fr["token"], fr["form_id"],
fr.get("submitted_at"), Jsonb(fr)))
return "", 204
Two details are easy to get wrong. Compute the HMAC over the raw bytes before any JSON parsing, because re-serialized JSON will not match the signature. And return 403, not 404, for a bad signature, since a 404 tells Typeform to disable the webhook entirely.
If the database is briefly down, the handler raises, Flask returns a 500, and Typeform retries on its back-off schedule. The upsert makes those retries safe. For more resilience, write the raw body to a queue or a staging table and return immediately, then process it in a worker.
Route 2: the Responses API for history and batches
Webhooks only see submissions made after you set them up. For everything before that, or if you prefer a scheduled job to a public endpoint, page through Retrieve responses. It returns up to 1,000 items per page, newest first, and passing the last item’s token as before walks back through history.
import os
import psycopg
import requests
from psycopg.types.json import Jsonb
TOKEN = os.environ["TYPEFORM_TOKEN"]
DSN = os.environ["DATABASE_URL"]
FORM_ID = "u6nXL7"
UPSERT = """
INSERT INTO typeform_responses (response_id, form_id, submitted_at, payload)
VALUES (%s, %s, %s, %s)
ON CONFLICT (response_id) DO UPDATE
SET submitted_at = EXCLUDED.submitted_at,
payload = EXCLUDED.payload,
loaded_at = now()
"""
def pages(since=None):
params = {"page_size": 1000, "completed": "true"}
if since:
params["since"] = since
while True:
r = requests.get(
f"https://api.typeform.com/forms/{FORM_ID}/responses",
headers={"Authorization": f"Bearer {TOKEN}"},
params=params,
timeout=60,
)
r.raise_for_status()
items = r.json().get("items", [])
if not items:
return
yield items
params["before"] = items[-1]["token"]
with psycopg.connect(DSN) as conn:
with conn.cursor() as cur:
for items in pages():
cur.executemany(UPSERT, [
(it["response_id"], FORM_ID, it.get("submitted_at"), Jsonb(it))
for it in items
])
Run it once without since for a full backfill, then switch on the webhook. If you stay batch-only, pass the latest submitted_at you have stored as since on each run. Accounts hosted in Typeform’s EU data center use the EU API base URL instead of api.typeform.com, so check which one your account needs.
One difference between the two routes: API items carry answers and hidden but not the form definition, while webhook payloads include definition with every question’s title and ref. If you mix sources, read question titles from the Create API form definition rather than from individual rows.
Flattening answers into rows
Each answer has a field object and a type, with the value stored under a key named after that type: text, email, number, boolean, date, url, choice, choices, and a few more. PostgreSQL’s JSON functions turn the array into one row per answer:
CREATE VIEW typeform_answers AS
SELECT r.response_id,
r.form_id,
r.submitted_at,
a->'field'->>'id' AS field_id,
a->'field'->>'ref' AS field_ref,
a->>'type' AS answer_type,
CASE a->>'type'
WHEN 'choice' THEN a->'choice'->>'label'
WHEN 'choices' THEN coalesce(a->'choices'->'labels', a->'choices')::text
ELSE a->>(a->>'type')
END AS answer_value
FROM typeform_responses r
CROSS JOIN LATERAL jsonb_array_elements(r.payload->'answers') AS a;
The ELSE branch reads the key whose name matches the answer type, which covers text, email, number, boolean, date, URL, and file answers without listing each one. Multi-select answers come back as a JSON array of labels cast to text, and checkbox answers, which Typeform sends as an array of choice objects, fall back to the whole array, so nothing is silently dropped.
Join on field_id rather than field_ref unless you set refs yourself. Typeform’s docs warn that system-generated refs on forms built in the admin panel are not persistent and can differ between payloads, while field IDs stay stable. If you control the form, giving each question a readable ref before launch makes every query easier to read.
From here a per-form view can pivot answers into columns with max(answer_value) FILTER (WHERE field_id = '...'), and any BI tool that speaks Postgres can sit on top.
Route 3: AppFlow, Airbyte, or Fivetran
If you would rather configure than code, three managed options load Typeform into Postgres:
- Amazon AppFlow. The Typeform connector lists Amazon RDS for PostgreSQL as a destination, with a Response object that supports
sinceanduntilfilters for incremental flows. The destination is RDS specifically, so a self-hosted server or another provider’s Postgres needs a different route. - Airbyte. Its Typeform source syncs Responses incrementally on
response_id, and its Postgres destination works with any reachable Postgres server, including self-hosted ones. - Fivetran. Its Typeform connector loads normalized response and answer tables into a PostgreSQL destination. Fivetran’s docs note that deleted responses cannot be captured because the Typeform API does not report deletions.
The trade-off is the same as with any ELT tool. You get scheduling, retries, and a pre-built schema, and you accept sync intervals measured in minutes or hours rather than seconds, plus the vendor’s pricing model. The deletion limit also applies to your own API job: if responses get deleted in Typeform, plan a periodic reconciliation rather than expecting them to vanish from Postgres.
When the form never reached Typeform
Some answers never get a response ID. A paper version of the survey handed out at an event, a PDF a partner filled in and emailed back, a photo of a signup sheet. If those should land in the same reports, read the page into JSON with matching keys and insert it with its own source.
With OCRskill you list the fields you want and get JSON back:
curl "https://api.ocrskill.com/ocr.json?fields=last_name,first_name,email,company_name?" \
-H "Authorization: Bearer sk-your-key-here" \
-F "file=@event-signup-scan.jpg"
Required fields return a 400 when the page cannot support them, so an unreadable scan does not become a half-empty row. Optional keys end in ?. Insert each result with the file name as response_id, source = 'paper', and the extracted fields reshaped into the same answers structure, and the view above treats them like any other response. The form data extraction API post covers form OCR in more depth, and the OCR JSON API reference lists supported field names.
Which route to pick
| Situation | Route |
|---|---|
| New submissions in Postgres within seconds | Signed webhook plus upsert |
| Full history, or no public endpoint allowed | Responses API batch job |
| AWS shop already on RDS for PostgreSQL | Amazon AppFlow |
| Many SaaS sources already in an ELT tool | Airbyte or Fivetran |
| Paper, PDF, or photo forms | OCR to JSON, inserted with its own source |
Conclusion
A Typeform to PostgreSQL export is mostly a schema decision. Key the table on response_id, keep the raw response in jsonb, and write with ON CONFLICT DO UPDATE, and the webhook, the API backfill, and any managed sync can all feed it without stepping on each other. The details that cause real incidents are small ones: computing the signature over raw bytes, never answering Typeform with a 404, joining answers on field IDs instead of auto-generated refs, and remembering that deletions in Typeform will not reach your database unless you reconcile them yourself.
