IntegrateWebhooks
Integrate

Webhooks

Receive signed HTTP requests when a payment or a payout changes status, and verify them.

Markdown

Turmite sends an HTTP POST to your server when a payment or a payout changes status. Each request is signed. Verify the signature before you use the body.

Kinds of webhook

Kind Where you set it Body
Per-payment IPN ipn_callback_url in POST /v1/payment or POST /v1/invoice The flat payment object
Per-payout IPN ipn_callback_url of a withdrawal or of the batch in POST /v1/payout The flat payout object
Project endpoint Dashboard → Webhooks → Endpoint URL → Add endpoint An envelope with event, delivery_id, created_at and data

The per-payment IPN works with code written for the common hosted-gateway API. A payment made from an invoice sends its IPN to the invoice's ipn_callback_url. Project endpoints receive payment events, payout events and test events. A user with the developer role can add and delete endpoints.

Turmite sends a payment event at each status change, also when a payment returns to an earlier status. After finished, a later deposit sends the finished event again with the new actually_paid, and its credit sends it with the new outcome_amount, for example for a late top-up. The per-payment IPN also gets an event at each new confirmation count while the status is confirming.

Example body for a per-payment IPN:

{
  "actually_paid": 0.00041235,
  "created_at": "2026-10-23T12:00:00.000Z",
  "expiration_estimate_date": "2026-10-23T13:00:00.000Z",
  "invoice_id": null,
  "network": "btc",
  "network_precision": 8,
  "order_description": "Order 1001",
  "order_id": "ORDER-1001",
  "outcome_amount": 0.00041235,
  "outcome_currency": "btc",
  "pay_address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
  "pay_amount": 0.00041235,
  "pay_currency": "btc",
  "payin_extra_id": null,
  "payin_hash": "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b",
  "payment_id": 5524759814,
  "payment_status": "finished",
  "payment_url": "https://pay.example.com/pay/Q7mX2pK9vT4nB8cR1sW6yZ3h",
  "payout_hash": null,
  "price_amount": 25,
  "price_currency": "usd",
  "purchase_id": "4709931082",
  "smart_contract": null,
  "updated_at": "2026-10-23T12:41:07.000Z"
}

Example body for a project endpoint:

{
  "event": "payment.finished",
  "delivery_id": "dK4rT8wQ2mN6xP1vB9cL5zH7",
  "created_at": 1792759268,
  "data": {
    "payment_id": 5524759814,
    "payment_status": "finished",
    "order_id": "ORDER-1001"
  }
}

data holds the full payment object or payout object. The example shows three fields only. A test event has {"message": "Test event from Turmite."} in data. delivery_id is a random token of 24 letters and digits, the same value as the x-turmite-delivery header. created_at is Unix time in seconds.

In both bodies, payment_id is a number and purchase_id is a string.

Events

Event Sent when
payment.<status> A payment gets a new status, for example payment.confirming or payment.finished
payout.<status> A payout gets a new status, for example payout.sent or payout.needs-review
test You click Send test in the dashboard

The payment statuses are in Payment statuses. The payout statuses are in Payouts.

Headers

Every delivery has these headers.

Header Value
content-type application/json
Signature header, default x-turmite-sig Hex HMAC-SHA512 of the key-sorted JSON body. Key: the secret of the webhook. See The secrets
x-turmite-timestamp Unix time in seconds when Turmite signed this attempt
x-turmite-signature v1= and the hex HMAC-SHA256 of <timestamp>.<raw body>. Key: the same secret
x-turmite-event Event name, for example payment.finished
x-turmite-delivery Delivery ID. Retries of one delivery use the same ID
user-agent turmite-webhook/1

A project admin can change the name of the signature header in Settings → General → IPN signature header. Set it to the header name that your existing integration reads. The value does not change. The name can have up to 64 letters, digits and hyphens. Turmite stores it in lowercase. It cannot start with x-turmite-, except x-turmite-sig.

The secrets

Both signatures of one delivery use the same secret. Which secret depends on the kind of webhook.

Webhook Secret Where to get it
ipn_callback_url of a payment, an invoice or a withdrawal The project IPN secret Settings → IPN secret. Enter a fresh two-factor code and click Show secret
Project endpoint The signing secret of that endpoint Shown one time under Signing secret, when you add the endpoint

To change the IPN secret, a project admin clicks Rotate under IPN secret and enters a fresh two-factor code. To change an endpoint secret, add a new endpoint, switch your receiver to it, then delete the old endpoint.

Each secret is 48 hex characters. Turmite stores each secret sealed with TURMITE_SECRET. Keep the secrets on your server, with the API key.

Which signature to use

  • For new code, verify x-turmite-signature. It covers a timestamp, so you can refuse replayed requests.
  • For an existing integration, keep the signature check that it has. Set the signature header name in Settings if your code reads another name.

You can check both.

Verify the timestamp signature

  1. Read the raw body bytes before you parse JSON.
  2. Read x-turmite-timestamp. Refuse the request if it is more than 5 minutes from your clock.
  3. Calculate HMAC-SHA256 with the secret over the timestamp, a full stop and the raw body.
  4. Encode the result as lowercase hex and add v1= before it.
  5. Compare the result with x-turmite-signature in constant time.

The examples use the IPN secret in TM_IPN_SECRET. For a project endpoint, use the secret of the endpoint.

Node.js

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

const secret = process.env.TM_IPN_SECRET;

function verifyV1(rawBody, headers) {
  const ts = String(headers['x-turmite-timestamp'] ?? '');
  const sig = String(headers['x-turmite-signature'] ?? '');
  if (!Buffer.isBuffer(rawBody)) return false;
  if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const mac = crypto.createHmac('sha256', secret).update(`${ts}.`).update(rawBody).digest('hex');
  const want = Buffer.from(`v1=${mac}`);
  const got = Buffer.from(sig);
  return want.length === got.length && crypto.timingSafeEqual(want, got);
}

const app = express();
app.post('/turmite/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyV1(req.body, req.headers)) return res.sendStatus(401);
  const event = JSON.parse(req.body.toString('utf8'));
  // Update the order here. Make this step idempotent.
  res.sendStatus(200);
});
app.listen(3000);

PHP

<?php
$secret = getenv('TM_IPN_SECRET');
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_TURMITE_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_TURMITE_SIGNATURE'] ?? '';

$want = 'v1=' . hash_hmac('sha256', $ts . '.' . $raw, $secret);
if (!ctype_digit($ts) || abs(time() - (int) $ts) > 300 || !hash_equals($want, $sig)) {
    http_response_code(401);
    exit;
}

$event = json_decode($raw, true);
// Update the order here. Make this step idempotent.
http_response_code(200);

Python

import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["TM_IPN_SECRET"].encode()


def verify_v1(raw: bytes, headers) -> bool:
    ts = headers.get("x-turmite-timestamp", "")
    sig = headers.get("x-turmite-signature", "")
    if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        return False
    mac = hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest("v1=" + mac, sig)


@app.post("/turmite/webhook")
def webhook():
    raw = request.get_data()
    if not verify_v1(raw, request.headers):
        abort(401)
    event = request.get_json()
    # Update the order here. Make this step idempotent.
    return "", 200

Go

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
	"os"
	"strconv"
	"time"
)

var secret = []byte(os.Getenv("TM_IPN_SECRET"))

func verifyV1(h http.Header, body []byte) bool {
	ts := h.Get("X-Turmite-Timestamp")
	n, err := strconv.ParseInt(ts, 10, 64)
	if err != nil {
		return false
	}
	if d := time.Now().Unix() - n; d > 300 || d < -300 {
		return false
	}
	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(ts + "."))
	mac.Write(body)
	want := "v1=" + hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(want), []byte(h.Get("X-Turmite-Signature")))
}

func webhook(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil || !verifyV1(r.Header, body) {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}
	// Parse body and update the order here. Make this step idempotent.
	w.WriteHeader(http.StatusOK)
}

func main() {
	http.HandleFunc("POST /turmite/webhook", webhook)
	http.ListenAndServe(":3000", nil)
}

Ruby

require "json"
require "openssl"
require "sinatra"

SECRET = ENV.fetch("TM_IPN_SECRET")

def verify_v1(raw, ts, sig)
  return false unless ts.to_s.match?(/\A\d+\z/)
  return false if (Time.now.to_i - ts.to_i).abs > 300

  want = "v1=" + OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{ts}.#{raw}")
  OpenSSL.secure_compare(want, sig.to_s)
end

post "/turmite/webhook" do
  raw = request.body.read
  ts = request.env["HTTP_X_TURMITE_TIMESTAMP"]
  sig = request.env["HTTP_X_TURMITE_SIGNATURE"]
  halt 401 unless verify_v1(raw, ts, sig)

  event = JSON.parse(raw)
  # Update the order here. Make this step idempotent.
  status 200
end

Verify the compatible signature

This is the signature of the common hosted-gateway API. Existing integrations already check it.

  1. Make the key-sorted JSON from the raw body:
    • Sort the members of each object by key. Compare keys as UTF-8 bytes. Keep the order of arrays.
    • Write compact JSON with no whitespace.
    • Copy each number exactly as it is in the raw body.
    • In strings, escape only ", \ and control characters. Do not escape / or non-ASCII characters.
  2. Calculate HMAC-SHA512 with the secret over that text.
  3. Encode the result as lowercase hex.
  4. Compare the result with the signature header in constant time.

The body is the key-sorted JSON. Turmite writes numbers as JavaScript does: a whole amount as an integer (25), other amounts in the shortest decimal form (0.00041235, 0.000001), and an exponent only below 0.0000001 or from 1e21 (1e-7, 1.5e-7, 1e+21). In JavaScript, JSON.stringify of the parsed body with sorted keys gives the body again.

A JSON parser that changes the text of a number breaks the check. In Node.js, verifyIpnSignature from the TypeScript SDK keeps the text of each number. The Go example keeps it with UseNumber. The PHP, Python and Ruby examples keep integers and decimals from 0.0001 up. They can change a smaller number. For such amounts, use the timestamp signature.

The examples read the default header x-turmite-sig. If you changed the header name in Settings, read that name.

Node.js

import { verifyIpnSignature } from '@turmite/sdk';

// In an Express route with express.raw({ type: 'application/json' }):
const ok = await verifyIpnSignature(req.body, req.headers['x-turmite-sig'], process.env.TM_IPN_SECRET);
if (!ok) return res.sendStatus(401);
const payment = JSON.parse(req.body.toString('utf8'));

PHP

<?php
function tm_sort_keys($value) {
    if (is_array($value)) {
        if (!array_is_list($value)) {
            ksort($value, SORT_STRING);
        }
        foreach ($value as $key => $item) {
            $value[$key] = tm_sort_keys($item);
        }
    }
    return $value;
}

$secret = getenv('TM_IPN_SECRET');
$raw = file_get_contents('php://input');
$sig = strtolower($_SERVER['HTTP_X_TURMITE_SIG'] ?? '');
$data = json_decode($raw, true);

$canonical = json_encode(tm_sort_keys($data), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$want = hash_hmac('sha512', $canonical, $secret);
if (!is_array($data) || !hash_equals($want, $sig)) {
    http_response_code(401);
    exit;
}
// $data is the payment object. Update the order here.
http_response_code(200);

array_is_list needs PHP 8.1 or later.

Python

import hashlib
import hmac
import json


def verify_compat(raw: bytes, signature: str, ipn_secret: bytes) -> bool:
    canonical = json.dumps(
        json.loads(raw), sort_keys=True, separators=(",", ":"), ensure_ascii=False
    )
    want = hmac.new(ipn_secret, canonical.encode(), hashlib.sha512).hexdigest()
    return hmac.compare_digest(want, (signature or "").lower())


# Flask: verify_compat(request.get_data(), request.headers.get("x-turmite-sig"), SECRET)

Go

package webhook

import (
	"bytes"
	"crypto/hmac"
	"crypto/sha512"
	"encoding/hex"
	"encoding/json"
	"strings"
)

// Go encodes map keys in sorted order. UseNumber keeps each number as it was sent.
func VerifyCompat(body []byte, signature string, ipnSecret []byte) bool {
	dec := json.NewDecoder(bytes.NewReader(body))
	dec.UseNumber()
	var v any
	if err := dec.Decode(&v); err != nil {
		return false
	}
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	if err := enc.Encode(v); err != nil {
		return false
	}
	canonical := bytes.TrimSuffix(buf.Bytes(), []byte("\n"))
	mac := hmac.New(sha512.New, ipnSecret)
	mac.Write(canonical)
	want := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(want), []byte(strings.ToLower(signature)))
}

// In a handler: VerifyCompat(body, r.Header.Get("X-Turmite-Sig"), secret)

Ruby

require "json"
require "openssl"

def sort_keys(value)
  case value
  when Hash then value.keys.sort.to_h { |k| [k, sort_keys(value[k])] }
  when Array then value.map { |v| sort_keys(v) }
  else value
  end
end

def verify_compat(raw, signature, ipn_secret)
  canonical = JSON.generate(sort_keys(JSON.parse(raw)))
  want = OpenSSL::HMAC.hexdigest("SHA512", ipn_secret, canonical)
  OpenSSL.secure_compare(want, signature.to_s.downcase)
end

# Sinatra: verify_compat(request.body.read, request.env["HTTP_X_TURMITE_SIG"], ENV.fetch("TM_IPN_SECRET"))

Delivery rules

  • Turmite sends POST with a JSON body.
  • A 2xx response marks the attempt as delivered. Any other response or a timeout is a failure.
  • Turmite does not follow redirects. A 3xx response is a failure.
  • On mainnet, the URL must be an https URL. Turmite refuses private and reserved IP addresses when it connects, so a DNS record that points to a private address does not work.
  • On testnet, http URLs and private addresses such as localhost are allowed for testing.
  • Each attempt has a timeout of 30 seconds, with 10 seconds to connect.

Respond quickly. Do slow work, such as emails, after you send the response.

Retries

Kind Attempts
Progress events 3: now, then after 10 s and 60 s
Final events 11: now, then after 10 s, 1 m, 5 m, 30 m, 1 h, 2 h, 4 h, 8 h, 24 h and 48 h

After the last attempt, the delivery status becomes dead. A later status change sends a new event, so Turmite retries progress events fewer times.

Final events Progress events
payment.finished, payment.refunded, payment.expired, payment.failed payment.waiting, payment.confirming, payment.confirmed, payment.partially_paid
payout.sent, payout.failed, payout.rejected, payout.cancelled The other payout.* events, payout.needs-review included
test

payment.partially_paid is a progress event: the customer can still send the rest. payment.failed is in the list for compatibility. Turmite does not set the failed status on payments.

To send a delivery again, open Webhooks, find it under Deliveries and click Retry. Retry works for every delivery that is not in progress, also a delivered one. The list shows the last 50 deliveries.

Handle events safely

  • Make your handler idempotent. The same delivery can arrive more than one time. Store x-turmite-delivery and ignore repeats. Each target gets its own delivery ID for the same event.
  • Do not depend on the order of deliveries. Use payment_status and updated_at from the signed body. Ignore a status that is older than the one you stored.
  • Take the event from the signed body. x-turmite-event and x-turmite-delivery are not signed.
  • Check that order_id and price_amount match your order.
  • Ship the order on finished only.
  • For a large order, read the payment again with GET /v1/payment/{payment_id} before you ship.

Test webhooks

  1. Open Webhooks in the dashboard.
  2. Find the endpoint under Endpoints.
  3. Click Send test. Turmite sends a test event, signed with the endpoint secret.
  4. Look under Deliveries for the result and the number of tries.

Send test works for project endpoints only.

On a testnet install, pay with testnet coins to see real payment.* events. On your own computer, run Turmite with turmite serve --demo and point the endpoint at your local receiver. See Quick start.

A signature mismatch has two usual causes:

  • The framework parsed the body before your check. Read the raw bytes, for example with express.raw().
  • The secret is wrong or has extra spaces.