API
Webhooks
Get a signed POST when a conversion is done or failed. Payload, the IntoSQL-Signature header, retries, and verification in Node, Python and PHP.
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
- Open Settings, API keys.
- Under Webhook, enter the address and choose Save the address. You confirm your password first.
- 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 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:
{
"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):
{
"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:
{
"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; 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:
IntoSQL-Signature: t=1792159407,v1=5b0d2c6f0f1e8c3a9d4b7e2a1c6f8d3e0b9a4c7d2e5f8a1b3c6d9e2f5a8b1c4dVerify the signature
Check every call before you trust it:
- 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.
- Split the header on
,and each part on the first=to gettandv1. - Reject the call if
tis more than five minutes away from your clock. - Compute the HMAC-SHA256 of
t + "." + bodywith your secret, as lowercase hex. - Compare it to
v1in constant time.
Answer a call that fails the check with a 400 and ignore it.
Node, with the SDK
verifyWebhook from @intosql/sdk does all five steps and works in Node, Deno, Bun and edge runtimes.
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:
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:
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 "", 204PHP
file_get_contents('php://input') returns the raw body. In Laravel, use $request->getContent() and exclude the route from CSRF checks.
<?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:
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"