---
title: "Webhooks"
description: "Get a signed POST when a conversion is done or failed. Payload, the IntoSQL-Signature header, retries, and verification in Node, Python and PHP."
canonical: https://intosql.com/docs/api/webhooks
last_updated: 2026-10-15
---

# Webhooks

A webhook lets IntoSQL tell your server when a conversion ends, so your code doesn't have to keep reading it. Each call is an HTTPS `POST` with a small JSON body, signed with a secret only you and IntoSQL know.

> **Pro:** Webhooks are part of the API, which needs IntoSQL Pro.

## Set the address

1. Open [Settings, API keys](https://intosql.com/settings/api-keys).
2. Under **Webhook**, enter the address and choose **Save the address**. You confirm your password first.
3. Choose **Show** next to **Signing secret** and copy it into your server's configuration, for example as `INTOSQL_WEBHOOK_SECRET`.

The address must be HTTPS on port 443 or 8443, on a host whose addresses are all on the public internet. Private, local and reserved addresses are refused, and so is an address with a user name in it.

The signing secret starts with `whsec_`. It is made when you first save an address and stays the same when you change the address. **Remove the webhook** deletes both; saving an address again makes a new secret.

Each account has one webhook. It receives events for every conversion on the account.

## Events

| Type                  | When                                                                                                                                 |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `conversion.done`     | A conversion finished and its output is ready.                                                                                       |
| `conversion.failed`   | A conversion ended as failed: the engine refused the file, a file sent by URL couldn't be downloaded, or it ran past the time limit. |
| `conversion.canceled` | You canceled a conversion with `POST /conversions/{id}/cancel`.                                                                      |
| `ping`                | You chose **Send a test event** in Settings, API keys.                                                                               |

A request the API refuses outright, such as `invalid_spec` or `over_quota`, never creates a conversion, so it sends no event. Those come back as [errors](/docs/api/errors) on the request itself. A conversion deleted while it runs sends none either.

## Payload

Every event has the same envelope. `data` is the conversion exactly as `GET /conversions/{id}` returns it, so you rarely need to read it again:

```json
{
    "id": "evt_01k7r3j9qf8m2v6x4t0c5n1h8d",
    "type": "conversion.done",
    "created_at": "2026-10-16T14:03:27+00:00",
    "data": {
        "id": "01k7r3j9qf8m2v6x4t0c5n1h8d",
        "status": "done",
        "name": "ventas.csv",
        "bytes": 1048576,
        "report": {
            "table": "ventas",
            "rows": { "converted": 18204, "rejected": 0 }
        },
        "error": null,
        "output": { "bytes": 2310455, "url": "https://files.intosql.com/…" },
        "finished_at": "2026-10-16T14:03:27+00:00"
    }
}
```

A failed conversion carries the reason in `error`, with a stable code ([failed conversions](/docs/api/errors#failed-conversions)):

```json
{
    "id": "evt_01k7r3k2b8p4d6f8h0k2m4n6q8",
    "type": "conversion.failed",
    "created_at": "2026-10-16T17:03:27+00:00",
    "data": {
        "id": "01k7r3j9qf8m2v6x4t0c5n1h8d",
        "status": "failed",
        "error": {
            "code": "timed_out",
            "message": "The conversion took longer than our limit. Try a smaller file, or split it.",
            "problems": []
        },
        "output": null
    }
}
```

A test event:

```json
{
    "id": "evt_01k7r3k9c2e4g6j8m0p2r4t6w8",
    "type": "ping",
    "created_at": "2026-10-16T14:03:27+00:00",
    "data": { "sent_at": "2026-10-16T14:03:27+00:00" }
}
```

| Field        | Type   | Meaning                                                                                  |
| ------------ | ------ | ---------------------------------------------------------------------------------------- |
| `id`         | string | The event's id. The same on every retry of this event: use it to handle each event once. |
| `type`       | string | `conversion.done`, `conversion.failed`, `conversion.canceled` or `ping`.                 |
| `created_at` | string | When the event happened, ISO 8601.                                                       |
| `data`       | object | The [conversion](/docs/api/conversions); for `ping`, `sent_at`.                          |

`data.output.url` downloads the result for ten minutes from when the event was made. If you handle the event later, read the conversion with `GET /conversions/{id}` for a fresh address.

## Headers

| Header               | Value                 |
| -------------------- | --------------------- |
| `Content-Type`       | `application/json`    |
| `IntoSQL-Signature`  | `t=TIME,v1=SIGNATURE` |
| `IntoSQL-Webhook-Id` | The event's `id`      |

`TIME` is the Unix time in seconds when the call was signed. `SIGNATURE` is the HMAC-SHA256 of `TIME`, a dot and the raw request body, keyed with your signing secret, written as 64 lowercase hex characters:

```text
IntoSQL-Signature: t=1792159407,v1=5b0d2c6f0f1e8c3a9d4b7e2a1c6f8d3e0b9a4c7d2e5f8a1b3c6d9e2f5a8b1c4d
```

## Verify the signature

Check every call before you trust it:

1. Read the raw body as bytes or text, before any JSON parsing. Parsing and encoding it again changes the bytes and the signature won't match.
2. Split the header on `,` and each part on the first `=` to get `t` and `v1`.
3. Reject the call if `t` is more than five minutes away from your clock.
4. Compute the HMAC-SHA256 of `t + "." + body` with your secret, as lowercase hex.
5. Compare it to `v1` in constant time.

Answer a call that fails the check with a 400 and ignore it.

### Node, with the SDK

`verifyWebhook` from [`@intosql/sdk`](/docs/sdks/typescript) does all five steps and works in Node, Deno, Bun and edge runtimes.

```ts
import { verifyWebhook } from '@intosql/sdk';

export async function POST(request: Request): Promise<Response> {
    const body = await request.text();
    const valid = await verifyWebhook(
        body,
        request.headers.get('IntoSQL-Signature') ?? '',
        process.env.INTOSQL_WEBHOOK_SECRET!,
    );

    if (!valid) {
        return new Response(null, { status: 400 });
    }

    const event = JSON.parse(body);
    // handle event.type and event.data

    return new Response(null, { status: 204 });
}
```

Its fourth argument takes `toleranceSeconds` (default 300).

### Node, without the SDK

With Express, keep the raw body with `express.raw`:

```js
import crypto from 'node:crypto';
import express from 'express';

const app = express();

function verify(body, header, secret, toleranceSeconds = 300) {
    const parts = Object.fromEntries(
        header.split(',').map((part) => {
            const index = part.indexOf('=');
            return [part.slice(0, index).trim(), part.slice(index + 1).trim()];
        }),
    );
    const time = Number(parts.t);

    if (!Number.isInteger(time) || typeof parts.v1 !== 'string') {
        return false;
    }

    if (Math.abs(Math.floor(Date.now() / 1000) - time) > toleranceSeconds) {
        return false;
    }

    const expected = crypto
        .createHmac('sha256', secret)
        .update(`${time}.`)
        .update(body)
        .digest('hex');

    return (
        expected.length === parts.v1.length &&
        crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
    );
}

app.post(
    '/hooks/intosql',
    express.raw({ type: 'application/json' }),
    (request, response) => {
        const valid = verify(
            request.body,
            request.get('IntoSQL-Signature') ?? '',
            process.env.INTOSQL_WEBHOOK_SECRET,
        );

        if (!valid) {
            return response.sendStatus(400);
        }

        const event = JSON.parse(request.body.toString('utf8'));
        // handle event.type and event.data

        response.sendStatus(204);
    },
);
```

### Python

Standard library only. With Flask, `request.get_data()` returns the raw body:

```python
import hashlib
import hmac
import json
import os
import time

from flask import Flask, request

app = Flask(__name__)


def verify(body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(
        part.strip().split("=", 1) for part in header.split(",") if "=" in part
    )

    try:
        timestamp = int(parts["t"])
        signature = parts["v1"]
    except (KeyError, ValueError):
        return False

    if abs(time.time() - timestamp) > tolerance:
        return False

    expected = hmac.new(
        secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, signature)


@app.post("/hooks/intosql")
def intosql_webhook():
    body = request.get_data()

    if not verify(
        body,
        request.headers.get("IntoSQL-Signature", ""),
        os.environ["INTOSQL_WEBHOOK_SECRET"],
    ):
        return "", 400

    event = json.loads(body)
    # handle event["type"] and event["data"]

    return "", 204
```

### PHP

`file_get_contents('php://input')` returns the raw body. In Laravel, use `$request->getContent()` and exclude the route from CSRF checks.

```php
<?php

function verifyIntoSqlSignature(string $body, string $header, string $secret, int $tolerance = 300): bool
{
    $parts = [];

    foreach (explode(',', $header) as $part) {
        [$name, $value] = array_pad(explode('=', trim($part), 2), 2, '');
        $parts[$name] = $value;
    }

    if (! isset($parts['t'], $parts['v1']) || ! ctype_digit($parts['t'])) {
        return false;
    }

    if (abs(time() - (int) $parts['t']) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', $parts['t'].'.'.$body, $secret);

    return hash_equals($expected, $parts['v1']);
}

$body = file_get_contents('php://input');

if (! verifyIntoSqlSignature($body, $_SERVER['HTTP_INTOSQL_SIGNATURE'] ?? '', getenv('INTOSQL_WEBHOOK_SECRET'))) {
    http_response_code(400);
    exit;
}

$event = json_decode($body, true);
// handle $event['type'] and $event['data']

http_response_code(204);
```

## Replay protection

The signature covers the timestamp, so an old call can't be sent again with a new time. Rejecting calls whose `t` is more than five minutes from your clock stops someone who captured a call from replaying it later. Keep your server's clock synced with NTP.

Every attempt, retries included, is signed with the time it is sent, so retries pass this check.

## Respond and retry

Answer with any 2xx status within 10 seconds. The body of your answer is ignored. Redirects are not followed, so a 3xx counts as a failure.

If the call times out, can't connect, or gets any status other than 2xx, IntoSQL tries again. There are six attempts in all:

| Attempt | After the previous one |
| ------- | ---------------------- |
| 1       | right away             |
| 2       | 1 minute               |
| 3       | 5 minutes              |
| 4       | 30 minutes             |
| 5       | 2 hours                |
| 6       | 6 hours                |

After the sixth failure the event is dropped, about nine hours after the first attempt. The conversion itself is unaffected; read it with `GET /conversions/{id}` or list recent ones with `GET /conversions` to catch up.

To answer quickly, verify the signature, store the event or put it on your own queue, answer 204, and do the work (downloading the output, loading it into a database) afterwards.

## Handle events once

The same event can arrive more than once: an attempt can succeed on your side while the answer is lost, and the next attempt delivers it again. Events can also arrive out of order relative to other conversions.

Use the event's `id` (also in the `IntoSQL-Webhook-Id` header) as the key: every retry of an event carries the same one. Before acting, check whether you already handled it, for example with a unique index on it in the table where you record imports. To act on a conversion's latest state instead of the event's, read it with `GET /conversions/{id}`.

## Test it

Your endpoint must be reachable from the internet over HTTPS, so a server on `localhost` can't receive calls directly. Expose it through a tunnel such as ngrok or Cloudflare Tunnel, and save the tunnel's `https://` address as the webhook.

Then choose **Send a test event** in Settings, API keys. IntoSQL sends a `ping` event, signed like any other, to the saved address. You can send up to six test events a minute. A `ping` that fails is retried on the same schedule as other events.

To test your verification code without IntoSQL, sign a body yourself with your secret and send it:

```shell
SECRET=whsec_your_secret
BODY='{"id":"evt_test","type":"ping","created_at":"2026-10-16T14:03:27+00:00","data":{"sent_at":"2026-10-16T14:03:27+00:00"}}'
TIME=$(date +%s)
SIGNATURE=$(printf '%s' "$TIME.$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)
curl -X POST http://localhost:3000/hooks/intosql -H "Content-Type: application/json" -H "IntoSQL-Signature: t=$TIME,v1=$SIGNATURE" --data "$BODY"
```
