Appearance
Webhooks
Register an HTTPS endpoint and we'll POST a signed event to it when one of your swaps settles on-chain, so you don't have to poll GET /v1/swaps/{id}.
Register an endpoint
POST /v1/webhooks
{
"url": "https://your-app.com/webhooks/textile",
"eventTypes": ["swap.filled"]
}Requires webhooks:manage. The URL must be https. Four events exist: swap.filled (a swap you're tracking settled on-chain) and limit_order.filled / limit_order.expired / limit_order.cancelled (a limit order you submitted through the API moved through its lifecycle). The response includes a signingSecret — shown once — that you use to verify deliveries:
json
{
"data": {
"id": "whe_…",
"url": "https://your-app.com/webhooks/textile",
"eventTypes": ["swap.filled"],
"status": "active",
"signingSecret": "whsec_…"
}
}List with GET /v1/webhooks (secrets are never returned again) and remove with DELETE /v1/webhooks/{id}.
The event
We POST a JSON body like:
json
{
"id": "swap.filled:swp_…",
"type": "swap.filled",
"created": "2026-07-07T12:00:05.000Z",
"data": {
"swap": {
"id": "swp_…",
"chainId": 8453,
"txHash": "0x…",
"soldAmount": "1000000000",
"proceeds": "612000000",
"effectiveRateRay": "612000000000000000000000000",
"ordersFilled": 2,
"filledAt": "2026-07-07T12:00:05.000Z"
}
}
}Limit-order events carry the order in your frame — what you sold and what the fill pays you, exactly the amounts the order named:
json
{
"id": "limit_order.filled:…",
"type": "limit_order.filled",
"created": "2026-07-14T12:34:58.000Z",
"data": {
"limitOrder": {
"id": "…",
"chainId": 8453,
"sellToken": "0xCNGN",
"sellAmount": "1550000000",
"buyToken": "0xUSDT",
"buyAmount": "1089913",
"rateRay": "…",
"executionRateRay": "…",
"deadline": "2026-07-14T13:00:00.000Z",
"txHash": "0x…",
"filledAt": "2026-07-14T12:34:56.000Z"
}
}
}rateRay is the book rate (sellToken-per-buyToken); executionRateRay is the maker frame (buyToken-per-sellToken), matching /limit-orders/quote and prepare's assumedRateRay. limit_order.expired and limit_order.cancelled share the shape (txHash/filledAt are null). Expired and cancelled are notified with a deliberate ~10-minute lag past the deadline (plus the sweep's 5-minute cadence), so a fill that settled just before the deadline reconciles first; in the rare race where a cancel notification is followed by a late-reconciled fill, the limit_order.filled event supersedes it. Don't use these events as your repost trigger — run your own deadline timer and poll GET /v1/limit-orders/{id}, whose status is computed live (see the repost pattern).
Two headers come with it:
Textile-Event-Id— a stable id. Deliveries are at-least-once, so dedupe on this.Textile-Signature—t=<unix>,v1=<hmac>.
Verify the signature
The signature is HMAC-SHA256(signingSecret, "<t>.<rawBody>"), hex-encoded. Recompute it over the raw request body and compare:
ts
import crypto from 'crypto'
function verify(secret: string, header: string, rawBody: string): boolean {
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('=') as [string, string])
)
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(parts.v1, 'hex')
return a.length === b.length && crypto.timingSafeEqual(a, b)
}Reject anything that doesn't verify. You can also reject deliveries whose t is too far from now to guard against replay.
Delivery & retries
Return a 2xx quickly to acknowledge. Non-2xx (or a timeout) is retried with exponential backoff. Do your slow work after acknowledging, keyed on Textile-Event-Id so a retry is a no-op.