> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://typeform.ferndocs.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://typeform.ferndocs.com/_mcp/server.

# Webhooks API Walkthroughs

> Learn how to set up and manage webhooks to receive instant notifications when someone submits your typeform.

Webhooks allow you to receive real-time HTTP notifications when someone submits a response to your typeform. Instead of polling the Responses API, webhooks push data to your server immediately upon form submission.

## How webhooks work

When a respondent submits your typeform:

1. Typeform sends an HTTP POST request to your webhook URL
2. The request contains the complete response data
3. Your server processes the data and responds with a 200 status code
4. If delivery fails, Typeform retries automatically

> **Info**
>
> Webhooks require a publicly accessible HTTPS endpoint. Your server must
> respond within 10 seconds to be considered successful.

## Create a webhook

Use the Webhooks API to create a webhook for your form:

```bash
PUT https://api.typeform.com/forms/{form_id}/webhooks/{tag}
```

The `tag` is a unique identifier for your webhook (e.g., `production`, `staging`, `crm-sync`).

**Request body:**

```json
{
  "url": "https://your-server.com/webhooks/typeform",
  "enabled": true
}
```

**Example:**

```bash
curl -X PUT \
  'https://api.typeform.com/forms/abc123/webhooks/production' \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
    "url": "https://your-server.com/webhooks/typeform",
    "enabled": true
  }'
```

**Response:**

```json
{
  "id": "webhook_123xyz",
  "form_id": "abc123",
  "tag": "production",
  "url": "https://your-server.com/webhooks/typeform",
  "enabled": true,
  "created_at": "2024-01-15T10:00:00Z",
  "updated_at": "2024-01-15T10:00:00Z"
}
```

## List webhooks

Get all webhooks for a form:

```bash
GET https://api.typeform.com/forms/{form_id}/webhooks
```

**Response:**

```json
{
  "items": [
    {
      "id": "webhook_123xyz",
      "form_id": "abc123",
      "tag": "production",
      "url": "https://your-server.com/webhooks/typeform",
      "enabled": true,
      "created_at": "2024-01-15T10:00:00Z",
      "updated_at": "2024-01-15T10:00:00Z"
    }
  ]
}
```

## Get a specific webhook

```bash
GET https://api.typeform.com/forms/{form_id}/webhooks/{tag}
```

## Update a webhook

Use the same PUT endpoint to update an existing webhook:

```bash
PUT https://api.typeform.com/forms/{form_id}/webhooks/{tag}
```

**Request body:**

```json
{
  "url": "https://your-new-server.com/webhooks/typeform",
  "enabled": true
}
```

## Delete a webhook

```bash
DELETE https://api.typeform.com/forms/{form_id}/webhooks/{tag}
```

Successful deletion returns a `204 No Content` status.

## Webhook payload structure

When a form is submitted, Typeform sends a POST request to your webhook URL with this structure:

```json
{
  "event_id": "01HKMN8VCFX1ABC123XYZ",
  "event_type": "form_response",
  "form_response": {
    "form_id": "abc123",
    "token": "response_token_xyz",
    "landed_at": "2024-01-15T10:30:00Z",
    "submitted_at": "2024-01-15T10:35:25Z",
    "calculated": {
      "score": 85
    },
    "hidden": {
      "user_id": "12345",
      "source": "email"
    },
    "definition": {
      "id": "abc123",
      "title": "Customer Satisfaction Survey",
      "fields": [
        {
          "id": "field1",
          "title": "How satisfied are you?",
          "type": "opinion_scale",
          "ref": "satisfaction",
          "properties": {}
        }
      ]
    },
    "answers": [
      {
        "type": "number",
        "number": 9,
        "field": {
          "id": "field1",
          "type": "opinion_scale",
          "ref": "satisfaction"
        }
      }
    ]
  }
}
```

### Key components

* **event_id**: Unique identifier for this webhook event
* **event_type**: Always `"form_response"` for submissions
* **form_response**: Contains the complete response data
  * **form_id**: Your form's ID
  * **token**: Unique response token
  * **landed_at/submitted_at**: Timestamps
  * **calculated**: Scores and calculated values
  * **hidden**: Hidden field values
  * **definition**: Form structure (questions)
  * **answers**: Response data

> **Info**
>
> The webhook payload includes the form definition, so you have context about
> which questions the answers correspond to.

## Implementing a webhook endpoint

### Node.js/Express example

```javascript
const express = require('express');
const crypto = require('crypto');

const app = express();
app.use(express.json());

app.post('/webhooks/typeform', async (req, res) => {
  try {
    // 1. Verify the webhook (see security section)
    if (!verifyWebhookSignature(req)) {
      return res.status(401).send('Invalid signature');
    }

    // 2. Extract the response data
    const { event_id, event_type, form_response } = req.body;

    if (event_type !== 'form_response') {
      return res.status(200).send('OK'); // Acknowledge but ignore
    }

    // 3. Process the response
    await processResponse(form_response);

    // 4. Respond quickly (within 10 seconds)
    res.status(200).send('OK');

    // 5. Do heavy processing asynchronously
    setImmediate(async () => {
      await sendToAnalytics(form_response);
      await updateCRM(form_response);
      await sendNotificationEmail(form_response);
    });
  } catch (error) {
    console.error('Webhook error:', error);
    res.status(500).send('Internal server error');
  }
});

async function processResponse(formResponse) {
  // Extract answer values
  const answers = {};
  formResponse.answers.forEach((answer) => {
    const ref = answer.field.ref;

    switch (answer.type) {
      case 'text':
      case 'email':
      case 'url':
        answers[ref] = answer[answer.type];
        break;
      case 'number':
        answers[ref] = answer.number;
        break;
      case 'choice':
        answers[ref] = answer.choice.label;
        break;
      case 'choices':
        answers[ref] = answer.choices.labels;
        break;
      case 'boolean':
        answers[ref] = answer.boolean;
        break;
    }
  });

  // Save to database
  await db.responses.create({
    formId: formResponse.form_id,
    responseToken: formResponse.token,
    submittedAt: new Date(formResponse.submitted_at),
    answers: answers,
    hiddenFields: formResponse.hidden,
    score: formResponse.calculated?.score,
  });
}

app.listen(3000, () => {
  console.log('Webhook server listening on port 3000');
});
```

### Python/Flask example

```python
from flask import Flask, request, jsonify
import hmac
import hashlib
import json

app = Flask(__name__)

@app.route('/webhooks/typeform', methods=['POST'])
def typeform_webhook():
    try:
        # 1. Get the webhook data
        data = request.get_json()

        # 2. Verify signature (see security section)
        if not verify_signature(request):
            return jsonify({'error': 'Invalid signature'}), 401

        # 3. Process the response
        if data['event_type'] == 'form_response':
            process_response(data['form_response'])

        # 4. Return success quickly
        return jsonify({'status': 'success'}), 200

    except Exception as e:
        print(f'Error processing webhook: {e}')
        return jsonify({'error': 'Internal error'}), 500

def process_response(form_response):
    # Extract answers
    answers = {}
    for answer in form_response['answers']:
        ref = answer['field']['ref']
        answer_type = answer['type']

        if answer_type in ['text', 'email', 'url']:
            answers[ref] = answer[answer_type]
        elif answer_type == 'number':
            answers[ref] = answer['number']
        elif answer_type == 'choice':
            answers[ref] = answer['choice']['label']
        elif answer_type == 'choices':
            answers[ref] = answer['choices']['labels']
        elif answer_type == 'boolean':
            answers[ref] = answer['boolean']

    # Save to database
    save_to_database(form_response, answers)

if __name__ == '__main__':
    app.run(port=3000)
```

## Common use cases

### 1. Instant email notifications

Send email alerts immediately when forms are submitted:

```javascript
async function sendNotificationEmail(formResponse) {
  const answers = extractAnswers(formResponse);

  await emailService.send({
    to: 'team@company.com',
    subject: `New form submission: ${formResponse.form_id}`,
    html: formatAnswersAsHTML(answers),
  });
}
```

### 2. CRM integration

Automatically create or update CRM records:

```javascript
async function updateCRM(formResponse) {
  const answers = extractAnswers(formResponse);

  await crmAPI.createContact({
    email: answers.email,
    name: answers.name,
    company: answers.company,
    source: 'Typeform',
    formId: formResponse.form_id,
  });
}
```

### 3. Slack notifications

Post to Slack when important forms are submitted:

```javascript
async function sendToSlack(formResponse) {
  const answers = extractAnswers(formResponse);

  await slackAPI.postMessage({
    channel: '#customer-feedback',
    text: `New feedback received!`,
    attachments: [
      {
        fields: Object.entries(answers).map(([key, value]) => ({
          title: key,
          value: value,
          short: true,
        })),
      },
    ],
  });
}
```

### 4. Analytics tracking

Send events to analytics platforms:

```javascript
async function sendToAnalytics(formResponse) {
  await analyticsAPI.track({
    event: 'Form Submitted',
    properties: {
      formId: formResponse.form_id,
      formTitle: formResponse.definition.title,
      score: formResponse.calculated?.score,
      timestamp: formResponse.submitted_at,
    },
  });
}
```

## Testing webhooks locally

Use tools like ngrok to expose your local server:

```bash
# Install ngrok
npm install -g ngrok

# Start your local server
node server.js

# In another terminal, start ngrok
ngrok http 3000

# Use the ngrok URL (https://abc123.ngrok.io) as your webhook URL
```

## Retry mechanism

If your endpoint fails to respond with a 2xx status code, Typeform automatically retries:

* Immediate retry
* Retry after 1 minute
* Retry after 5 minutes
* Retry after 30 minutes
* Retry after 2 hours

After 5 failed attempts, the webhook is disabled.

> **Warning**
>
> Make sure your endpoint is idempotent. You may receive the same webhook
> multiple times due to retries, so use the `event_id` to deduplicate.

## Handling duplicates

```javascript
const processedEvents = new Set();

app.post('/webhooks/typeform', async (req, res) => {
  const { event_id } = req.body;

  // Check if we've already processed this event
  if (processedEvents.has(event_id)) {
    console.log(`Duplicate event ${event_id}, ignoring`);
    return res.status(200).send('OK');
  }

  // Process the event
  await processResponse(req.body.form_response);

  // Mark as processed
  processedEvents.add(event_id);

  res.status(200).send('OK');
});
```

For production, use a database or cache:

```javascript
async function isDuplicate(eventId) {
  const exists = await redis.get(`webhook:${eventId}`);
  if (exists) return true;

  await redis.setex(`webhook:${eventId}`, 86400, '1'); // 24 hour TTL
  return false;
}
```

## Best practices

1. **Respond quickly**: Return 200 status within 10 seconds
2. **Process asynchronously**: Do heavy work after responding
3. **Handle duplicates**: Use event_id to prevent duplicate processing
4. **Verify signatures**: Always verify webhook authenticity (see security guide)
5. **Log everything**: Keep detailed logs of webhook deliveries
6. **Monitor failures**: Track and alert on webhook failures
7. **Use HTTPS**: Webhooks require HTTPS endpoints
8. **Test thoroughly**: Test with actual form submissions before going live

## Monitoring webhooks

Track webhook health:

```javascript
const webhookMetrics = {
  received: 0,
  processed: 0,
  failed: 0,
  duplicates: 0,
};

app.post('/webhooks/typeform', async (req, res) => {
  webhookMetrics.received++;

  try {
    if (await isDuplicate(req.body.event_id)) {
      webhookMetrics.duplicates++;
      return res.status(200).send('OK');
    }

    await processResponse(req.body.form_response);
    webhookMetrics.processed++;
    res.status(200).send('OK');
  } catch (error) {
    webhookMetrics.failed++;
    console.error('Webhook processing failed:', error);
    res.status(500).send('Error');
  }
});

// Metrics endpoint
app.get('/metrics', (req, res) => {
  res.json(webhookMetrics);
});
```

## Troubleshooting

### Webhook not receiving data

1. Check that the webhook URL is publicly accessible
2. Verify the URL uses HTTPS
3. Check firewall and security group settings
4. Test the endpoint with curl

### Webhook disabled

If Typeform disables your webhook after failures:

1. Fix the endpoint issues
2. Re-enable via API:

```bash
PUT https://api.typeform.com/forms/{form_id}/webhooks/{tag}
{
  "url": "https://your-server.com/webhooks/typeform",
  "enabled": true
}
```

### Response timeout

If your endpoint times out:

1. Respond with 200 immediately
2. Queue processing for background workers
3. Optimize database queries
4. Use caching where possible

## Next steps

* Learn how to [secure your webhooks](/webhooks-api/secure-webhooks)
* See the complete [webhook payload structure](/webhooks-api/example-payload)
* Review the [API Reference](/api-reference) for webhook endpoints
* Compare with [polling responses](/responses-api/walkthroughs) approach