Webhooks
Webhooks send an HTTPS POST to your endpoint when something happens in your Matia account, such as a sync finishing or an integration being disabled. Use them to trigger downstream jobs, update your own UI, or alert your team without polling the Matia API. A webhook receives the events you choose for every integration and asset in your account.
Webhooks are enabled per account. To turn them on, contact Matia Support. Until they're enabled, every Webhooks API call returns 404.
This page covers event webhooks, which you manage through the API. To send issue alerts to a webhook URL, see Notification Integrations.
Set up a webhook
1. Prepare your endpoint
Your endpoint must:
- Use
https://. Matia rejectshttp://URLs. - Resolve only to public IP addresses. If any address the hostname resolves to is private, loopback, link-local, or otherwise reserved, the request is rejected.
- Respond with a
2xxstatus within 10 seconds. Anything else counts as a failed delivery, including redirects (3xx), which Matia doesn't follow.
To test from your own machine, expose your local server through a tunnel such as ngrok or Cloudflare Tunnel.
If you'll set a secret, configure your endpoint with it first: the test request Matia sends when you create the webhook is already signed.
2. Create the webhook
curl -X POST https://api.matia.io/v1/webhooks/account \
-H "x-api-key: $MATIA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/matia/webhooks",
"events": ["syncEnd", "integrationDisable"],
"active": true,
"secret": "a-long-random-string"
}'
| Field | Required | Description |
|---|---|---|
url | Yes | Your HTTPS endpoint. Each URL can be used by only one webhook in your account. |
events | Yes | One or more events, without duplicates. |
active | Yes | true to start delivering immediately. false creates the webhook disabled. |
secret | No | Used to sign every request. Strongly recommended. Generate one with openssl rand -hex 32. |
When active is true, Matia sends a test event to the URL before creating the webhook. If it fails, the webhook isn't created and the API returns a 400. To create the webhook before your endpoint is ready, set active to false and activate it later.
The response contains the new webhook. Matia never returns your secret: secret is "********" when one is set and null otherwise.
{
"code": "success",
"message": "Webhook created successfully",
"data": {
"id": "671a2f0c9e4b8d1a2c3e4f50",
"type": "account",
"url": "https://example.com/matia/webhooks",
"events": ["syncEnd", "integrationDisable"],
"active": true,
"secret": "********",
"createdAt": "2026-10-14T12:00:00.000Z",
"createdBy": "api"
}
}
3. Test the webhook
Call Test a webhook at any time to send a test event and see the result. It works on inactive webhooks too, so you can check a fix before reactivating.
{
"code": "success",
"message": "Webhook test completed",
"data": {
"succeed": false,
"status": 500,
"message": "Webhook endpoint test failed with HTTP 500"
}
}
A failed test still returns HTTP 200, with succeed: false. status is omitted when your endpoint didn't respond at all. Testing doesn't change the webhook or count toward automatic deactivation.
Events
| Event | Sent when | data |
|---|---|---|
syncStart | A sync run starts. | syncMode: how the run is expected to load tables. See below. |
syncEnd | A sync run finishes, whatever the outcome. | status: the outcome. See below.loadedRows: the run's committed records, meaning rows written and confirmed in the destination.message (optional): details reported for the run, such as an error. |
integrationCreateSuccess | A new integration finishes being created. | {} |
integrationCreateFailure | Creating an integration fails after Matia has accepted the request. Requests rejected up front, for example for invalid settings, don't send this event. | errorMessage (optional): why creation failed. |
integrationEnable | A disabled integration is enabled. | {} |
integrationDisable | An enabled integration is disabled. | reason (optional): present only when Matia disabled the integration automatically because its syncs kept failing. Meant for display; don't parse it. |
integrationDelete | An integration is deleted. | {} |
integrationResync | A Historical Data Re-Sync of the whole integration starts. Sent just before the syncStart of the run that performs it, with the same syncId. Re-syncing individual tables doesn't send this event. | {} |
assetCreate | An asset is created, whether a source, a destination, or another connection such as a BI tool or dbt. Also sent when an asset saved as a draft is connected. | {} |
You can't subscribe to test. Matia sends it only to check your endpoint.
syncStart sync mode
syncMode is Matia's prediction, when the run starts, of how it will load tables. Individual tables can still be reloaded during the run. See Sync Modes.
| Value | Meaning |
|---|---|
incremental | Every table loads only new or changed records. |
fullRefresh | Every table is reloaded. This includes the first run of a new integration, since no table has a saved position yet. |
mixed | Some tables are reloaded and others load incrementally. |
syncEnd status
| Value | Shown in Matia as | Meaning |
|---|---|---|
SUCCESS | Succeeded | Every table synced. |
FAILURE | Failed | Every table failed, or the run couldn't complete. |
PARTIAL | Incomplete | Some tables failed, or a post-run action failed. |
COMPLETED_WITH_ERRORS | Completed with errors | The run finished, but some records were rejected or invalid. |
ABORTED | Aborted | The run was stopped. |
Payloads
Every request has a JSON body. Top-level fields identify the event and what it's about. data holds the event-specific details.
On every event:
| Field | Description |
|---|---|
id | Unique ID of the event. Stays the same across retries, so use it to ignore duplicates. |
event | The event name. |
created | When the event happened, in ISO 8601 UTC. |
data | Event-specific details. {} when there are none. |
On every event except assetCreate and test:
| Field | Description |
|---|---|
integrationId, integrationName | The integration's ID and name. |
sourceType, sourceId | The source's connector type, such as shopify, and its asset ID. |
destinationType, destinationId | The destination's connector type, such as snowflake, and its asset ID. |
syncId | ID of the sync run. Only on syncStart, syncEnd, and integrationResync. |
On assetCreate only:
| Field | Description |
|---|---|
assetId, assetName | The asset's ID and name. |
assetType | The asset's connector type, such as snowflake. |
assetRole | How the asset is used: ETL_SOURCE, ETL_DESTINATION, RETL_SOURCE, RETL_DESTINATION, or MULTI_PURPOSE. null for assets that can't be an integration source, such as BI tools and dbt. |
Example payloads
- syncEnd
- syncStart
- integrationDisable
- integrationCreateFailure
- assetCreate
- test
{
"id": "9b2f6c1e-5d4a-4f3b-8e7a-2c1d0f9e8b7a",
"event": "syncEnd",
"created": "2026-10-14T12:05:42.118Z",
"integrationId": "66f1b2c3d4e5f60718293a4b",
"integrationName": "Shopify to Snowflake",
"sourceType": "shopify",
"sourceId": "66f1a0b1c2d3e4f506172839",
"destinationType": "snowflake",
"destinationId": "66f1a0b1c2d3e4f50617283a",
"syncId": "671a3b5d7e9f0a1b2c3d4e5f",
"data": {
"status": "FAILURE",
"loadedRows": 0,
"message": "Authentication failed: the access token has expired."
}
}
{
"id": "1f0e2d3c-4b5a-4968-8776-5a4b3c2d1e0f",
"event": "syncStart",
"created": "2026-10-14T12:00:01.553Z",
"integrationId": "66f1b2c3d4e5f60718293a4b",
"integrationName": "Shopify to Snowflake",
"sourceType": "shopify",
"sourceId": "66f1a0b1c2d3e4f506172839",
"destinationType": "snowflake",
"destinationId": "66f1a0b1c2d3e4f50617283a",
"syncId": "671a3b5d7e9f0a1b2c3d4e5f",
"data": {
"syncMode": "incremental"
}
}
{
"id": "7c6b5a49-3827-4615-a4b3-c2d1e0f9a8b7",
"event": "integrationDisable",
"created": "2026-10-14T03:00:12.904Z",
"integrationId": "66f1b2c3d4e5f60718293a4b",
"integrationName": "Shopify to Snowflake",
"sourceType": "shopify",
"sourceId": "66f1a0b1c2d3e4f506172839",
"destinationType": "snowflake",
"destinationId": "66f1a0b1c2d3e4f50617283a",
"data": {
"reason": "Integration automatically disabled after repeated failures over an extended period."
}
}
{
"id": "2a3b4c5d-6e7f-4809-9a1b-2c3d4e5f6a7b",
"event": "integrationCreateFailure",
"created": "2026-10-14T09:41:27.310Z",
"integrationId": "66f1c4d5e6f708192a3b4c5d",
"integrationName": "HubSpot to Snowflake",
"sourceType": "hubspot",
"sourceId": "66f1a0b1c2d3e4f50617284b",
"destinationType": "snowflake",
"destinationId": "66f1a0b1c2d3e4f50617283a",
"data": {
"errorMessage": "Insufficient privileges to create schema HUBSPOT in database ANALYTICS."
}
}
{
"id": "3c8e2a71-0f4b-4d6e-9a15-7b2d4c6e8f90",
"event": "assetCreate",
"created": "2026-10-14T11:58:03.402Z",
"assetId": "66f1a0b1c2d3e4f50617283a",
"assetName": "Analytics warehouse",
"assetType": "snowflake",
"assetRole": "ETL_DESTINATION",
"data": {}
}
{
"id": "e4f1a2b3-6c7d-4e8f-9a0b-1c2d3e4f5a6b",
"event": "test",
"created": "2026-10-14T12:00:00.000Z",
"data": {}
}
Request headers
| Header | Value |
|---|---|
Content-Type | application/json |
X-Matia-Event | The event name, the same as event in the body. |
X-Matia-Event-Id | The event ID, the same as id in the body. |
X-Matia-Delivery-Attempt | 1 for the first attempt, then 2 to 6 for retries. |
X-Matia-Signature | The signature. Only sent when the webhook has a secret. |
Verify signatures
When a webhook has a secret, every request, including test events, carries an X-Matia-Signature header:
X-Matia-Signature: t=1792065942,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
tis the Unix time, in seconds, when the request was signed. Every attempt is signed afresh, so retries carry a current timestamp.v1is the hex-encoded HMAC-SHA256 of<t>.<raw request body>, keyed with your secret.
To verify a request:
- Split the header on
,and readtandv1. - Compute the HMAC-SHA256 of
t, a period, and the raw request body exactly as received. Parsing and re-serializing the JSON changes the bytes and breaks the signature. - Compare your result with
v1using a constant-time comparison. - Reject the request if
tis more than a few minutes old, so a captured request can't be replayed later.
- Node.js (Express)
- Python (Flask)
import crypto from 'node:crypto';
import express from 'express';
const SECRET = process.env.MATIA_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
function isValidSignature(rawBody, header) {
if (!header || !Buffer.isBuffer(rawBody)) return false;
const parts = Object.fromEntries(header.split(',').map((part) => part.split('=')));
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac('sha256', SECRET).update(`${parts.t}.`).update(rawBody).digest();
const received = Buffer.from(parts.v1 ?? '', 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
const app = express();
// express.raw keeps the body as the exact bytes Matia signed.
app.post('/matia/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
if (!isValidSignature(req.body, req.get('X-Matia-Signature'))) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body);
if (event.event !== 'test') {
try {
await enqueue(event); // Process in a background job that skips event IDs it has already handled.
} catch {
return res.sendStatus(500); // Matia retries.
}
}
res.sendStatus(200);
});
import hashlib
import hmac
import os
import time
from flask import Flask, request
SECRET = os.environ["MATIA_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300
def is_valid_signature(raw_body: bytes, header: str | None) -> bool:
if not header:
return False
try:
parts = dict(part.split("=", 1) for part in header.split(","))
except ValueError:
return False
timestamp = parts.get("t", "")
if not timestamp.isdecimal() or abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), parts.get("v1", "").encode())
app = Flask(__name__)
@app.post("/matia/webhooks")
def matia_webhook():
# get_data() returns the exact bytes Matia signed.
if not is_valid_signature(request.get_data(), request.headers.get("X-Matia-Signature")):
return "", 401
event = request.get_json()
if event["event"] != "test":
enqueue(event) # Process in a background job that skips event IDs it has already handled.
return "", 200
To rotate a secret, update the webhook with a new secret. If the webhook is active, Matia tests your endpoint with the new secret before saving it. From then on, every request, including retries of earlier events, is signed with the new secret. Accept both secrets on your endpoint during the switch.
Delivery and retries
Retries
A delivery fails if your endpoint doesn't respond with a 2xx within 10 seconds or can't be reached. Matia retries it on a fixed schedule, measured from the first attempt:
| Attempt | Time after the first attempt |
|---|---|
| 1 | Immediately |
| 2 | 6 minutes |
| 3 | 21 minutes |
| 4 | 1 hour 45 minutes |
| 5 | 6 hours 25 minutes |
| 6 (last) | 24 hours |
After the 6th failed attempt, Matia stops retrying that event. Each event is retried independently, so a failing delivery doesn't hold back later events.
What to expect
- Duplicates. If your endpoint processes an event but doesn't respond with a
2xxin time, Matia sends it again. Use the eventidto skip events you've already handled. - Any order. Events can arrive out of order, especially after retries. Use
createdto order them, andsyncIdto match asyncStartwith itssyncEnd. - Gaps. Webhooks are best-effort. For example, a
syncStartisn't always followed by asyncEnd. For anything critical, confirm with the API, for example with Get the status of a specific integration run, passingintegrationIdandsyncIdas the run ID. - Respond fast. Verify the request, hand the event to a background job, and return a
2xx, as the samples above do. If queuing fails, return an error so Matia retries.
Automatic deactivation
If deliveries to a webhook keep failing for 72 hours without a single success, Matia deactivates the webhook and cancels its pending deliveries. Events that happen while a webhook is inactive aren't delivered later.
Matia doesn't notify you when this happens. If events stop arriving, check active with Get a webhook.
To reactivate, fix your endpoint, then update the webhook with "active": true. You can change the url in the same request. Matia tests the endpoint first and reactivates the webhook only if the test succeeds.
Disabling a webhook yourself ("active": false) also stops new events from being queued. A pending retry is cancelled if it comes due while the webhook is disabled; if you reactivate first, it's still delivered.
Manage webhooks
All requests go to https://api.matia.io/v1 and authenticate with your API key in the x-api-key header.
| Action | Endpoint |
|---|---|
| List webhooks | GET /webhooks |
| Create a webhook | POST /webhooks/account |
| Get a webhook | GET /webhooks/{id} |
| Update a webhook | PATCH /webhooks/{id} |
| Delete a webhook | DELETE /webhooks/{id} |
| Test a webhook | POST /webhooks/{id}/test |
Updates change only the fields you send. Matia sends a test event before saving an update that reactivates a webhook, or that changes the url or secret of an active one. Changing only events doesn't. Deleting a webhook also deletes its pending deliveries.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
404 on every Webhooks API call | Webhooks aren't enabled for your account. | Contact Matia Support. |
400 with BadRequest_WebhookEndpointTestFailed | The test event failed. If your endpoint responded, message includes the HTTP status, for example Webhook endpoint test failed with HTTP 401. If it didn't respond, message is just Webhook endpoint test failed: it timed out after 10 seconds, couldn't be reached, or resolves to a non-public address. | Fix the endpoint and retry. A 401 usually means your endpoint doesn't have the webhook's secret yet. |
409 with Conflict_WebhookUrl | Another webhook in your account already uses this URL. | Update the existing webhook, or use a different URL. |
| Events stopped arriving | The webhook is inactive, usually because it was deactivated after failing for 72 hours. Or the event was removed from events. | Check active and events with Get a webhook. Fix the endpoint, then set "active": true. |
| Signature doesn't match | The body was parsed or re-encoded before verifying, or the secret is wrong. | See Verify signatures. |
Related
- Notification Integrations: send issue alerts to Slack, Microsoft Teams, PagerDuty, or a webhook URL.
- Sync Logs and Run History: see the runs behind
syncStartandsyncEndevents.