Payments

Callbacks

Receive and verify signed payment.paid, payment.refunded and payout.completed events.

When an order reaches its final state, HTP POSTs a JSON event to the callback URL you set in the console.

Events

event When data
payment.paid The user paid The payment, status: "PAID", user_sub set
payment.refunded A refund succeeded The refund order: order_type: "REFUND", parent_id = payment id
payout.completed A payout succeeded The payout order

Payments that expire or that you close do not send a callback.

Request

http
POST /hooks/htp?shop=1 HTTP/1.1
Host: shop.example.com
Content-Type: application/json
X-Event: payment.paid
X-Timestamp: 1730970312
X-Nonce: 3q2-7wEAAAB8n9Kx1aBcDg
Idempotency-Key: 9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b
X-Signature: 1b2Yt…=
json
{
  "event_id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "event": "payment.paid",
  "created_at": "2026-11-07T09:05:12Z",
  "data": {
    "id": "0b7a9c62-3f1e-4a2b-9d6c-5e4f3a2b1c0d",
    "out_order_no": "A20261107-0001",
    "order_type": "PAYMENT",
    "environment": "PRODUCTION",
    "status": "PAID",
    "amount": "40.00",
    "refunded_amount": "0.00",
    "fee_amount": "0.40",
    "net_amount": "39.60",
    "currency": "USDT",
    "description": "Premium membership, 1 month",
    "user_sub": "2f6c0d1e8a9b4c7d",
    "expires_at": "2026-11-07T09:30:00Z",
    "completed_at": "2026-11-07T09:05:12Z",
    "created_at": "2026-11-07T09:00:00Z"
  }
}

Verify the signature

The signature uses the same scheme as API requests, with your callback signing secret:

text
string_to_sign = "POST"                          + "\n" +
                 path_and_query_of_callback_url  + "\n" +
                 X-Timestamp                     + "\n" +
                 X-Nonce                         + "\n" +
                 Idempotency-Key                 + "\n" +   // = event_id
                 hex(sha256(raw_body))

X-Signature = base64( HMAC-SHA256( callback_secret, string_to_sign ) )
  • Use the path and query of the callback URL as configured in the console (/hooks/htp?shop=1 above; / if the URL has no path). If a proxy rewrites paths, take it from your configuration, not from the incoming request.
  • Hash the raw body bytes before any JSON parsing.
  • Compare in constant time, and reject timestamps more than 5 minutes off.
Node.js
import crypto from 'node:crypto';
import express from 'express';

const CALLBACK_SECRET = process.env.HTP_CALLBACK_SECRET;
const CALLBACK_URL = new URL(process.env.HTP_CALLBACK_URL); // as set in the console
const CALLBACK_PATH = CALLBACK_URL.pathname + CALLBACK_URL.search;

const app = express();

app.post(CALLBACK_URL.pathname, express.raw({ type: '*/*' }), async (req, res) => {
  const ts = req.get('X-Timestamp') ?? '';
  const nonce = req.get('X-Nonce') ?? '';
  const eventId = req.get('Idempotency-Key') ?? '';
  const given = Buffer.from(req.get('X-Signature') ?? '', 'base64');

  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(401);
  const bodyHash = crypto.createHash('sha256').update(req.body).digest('hex');
  const expected = crypto
    .createHmac('sha256', CALLBACK_SECRET)
    .update(['POST', CALLBACK_PATH, ts, nonce, eventId, bodyHash].join('\n'))
    .digest();
  if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  res.sendStatus(200); // acknowledge fast…
  await handleEvent(event); // …then do the work (or enqueue it)
});
Python
import base64, hashlib, hmac, json, os, time
from urllib.parse import urlsplit
from flask import Flask, request, abort

CALLBACK_SECRET = os.environ["HTP_CALLBACK_SECRET"].encode()
u = urlsplit(os.environ["HTP_CALLBACK_URL"])  # as set in the console
CALLBACK_PATH = (u.path or "/") + (f"?{u.query}" if u.query else "")

app = Flask(__name__)

@app.post(u.path or "/")
def htp_callback():
    raw = request.get_data()  # raw bytes, before parsing
    ts = request.headers.get("X-Timestamp", "")
    nonce = request.headers.get("X-Nonce", "")
    event_id = request.headers.get("Idempotency-Key", "")
    given = request.headers.get("X-Signature", "")

    if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        abort(401)
    sts = "\n".join(["POST", CALLBACK_PATH, ts, nonce, event_id, hashlib.sha256(raw).hexdigest()])
    expected = base64.b64encode(hmac.new(CALLBACK_SECRET, sts.encode(), hashlib.sha256).digest()).decode()
    if not hmac.compare_digest(expected, given):
        abort(401)

    enqueue(json.loads(raw))  # process asynchronously
    return "", 200
Go
func HTPCallback(secret []byte, callbackPath string, handle func(Event) error) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
		if err != nil {
			http.Error(w, "bad body", http.StatusBadRequest)
			return
		}
		ts := r.Header.Get("X-Timestamp")
		sec, err := strconv.ParseInt(ts, 10, 64)
		if err != nil || math.Abs(float64(time.Now().Unix()-sec)) > 300 {
			http.Error(w, "stale", http.StatusUnauthorized)
			return
		}
		sum := sha256.Sum256(raw)
		sts := strings.Join([]string{"POST", callbackPath, ts, r.Header.Get("X-Nonce"),
			r.Header.Get("Idempotency-Key"), hex.EncodeToString(sum[:])}, "\n")
		mac := hmac.New(sha256.New, secret)
		mac.Write([]byte(sts))
		given, _ := base64.StdEncoding.DecodeString(r.Header.Get("X-Signature"))
		if !hmac.Equal(given, mac.Sum(nil)) {
			http.Error(w, "bad signature", http.StatusUnauthorized)
			return
		}
		var ev Event
		if err := json.Unmarshal(raw, &ev); err != nil {
			http.Error(w, "bad json", http.StatusBadRequest)
			return
		}
		if err := handle(ev); err != nil { // idempotent, quick
			http.Error(w, "retry", http.StatusInternalServerError)
			return
		}
		w.WriteHeader(http.StatusOK)
	}
}
PHP
<?php
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
$nonce = $_SERVER['HTTP_X_NONCE'] ?? '';
$eventId = $_SERVER['HTTP_IDEMPOTENCY_KEY'] ?? '';
$given = $_SERVER['HTTP_X_SIGNATURE'] ?? '';

$u = parse_url(getenv('HTP_CALLBACK_URL')); // as set in the console
$path = ($u['path'] ?? '/') . (isset($u['query']) ? '?' . $u['query'] : '');

if (!ctype_digit($ts) || abs(time() - (int) $ts) > 300) {
    http_response_code(401);
    exit;
}
$sts = implode("\n", ['POST', $path, $ts, $nonce, $eventId, hash('sha256', $raw)]);
$expected = base64_encode(hash_hmac('sha256', $sts, getenv('HTP_CALLBACK_SECRET'), true));
if (!hash_equals($expected, $given)) {
    http_response_code(401);
    exit;
}

$event = json_decode($raw, true);
enqueue($event);
http_response_code(200);
Java
// Spring Boot
@RestController
public class HtpCallbackController {
    private final byte[] secret = System.getenv("HTP_CALLBACK_SECRET").getBytes(StandardCharsets.UTF_8);
    private final String callbackPath; // path + query of the callback URL set in the console

    public HtpCallbackController() {
        URI u = URI.create(System.getenv("HTP_CALLBACK_URL"));
        String path = (u.getRawPath() == null || u.getRawPath().isEmpty()) ? "/" : u.getRawPath();
        this.callbackPath = u.getRawQuery() == null ? path : path + "?" + u.getRawQuery();
    }

    @PostMapping(value = "/hooks/htp", consumes = MediaType.ALL_VALUE)
    public ResponseEntity<Void> callback(
            @RequestBody byte[] raw, // raw bytes, before parsing
            @RequestHeader("X-Timestamp") String ts,
            @RequestHeader("X-Nonce") String nonce,
            @RequestHeader("Idempotency-Key") String eventId,
            @RequestHeader("X-Signature") String signature) throws Exception {
        long sec;
        try {
            sec = Long.parseLong(ts);
        } catch (NumberFormatException e) {
            return ResponseEntity.status(401).build();
        }
        if (Math.abs(Instant.now().getEpochSecond() - sec) > 300) return ResponseEntity.status(401).build();

        String bodyHash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(raw));
        String sts = String.join("\n", "POST", callbackPath, ts, nonce, eventId, bodyHash);
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret, "HmacSHA256"));
        byte[] expected = mac.doFinal(sts.getBytes(StandardCharsets.UTF_8));
        byte[] given;
        try {
            given = Base64.getDecoder().decode(signature);
        } catch (IllegalArgumentException e) {
            return ResponseEntity.status(401).build();
        }
        if (!MessageDigest.isEqual(expected, given)) return ResponseEntity.status(401).build();

        events.enqueue(new String(raw, StandardCharsets.UTF_8)); // process async, idempotent on event_id
        return ResponseEntity.ok().build();
    }
}

Test your verifier

Paste a real delivery into the Callback verifier to see the expected string_to_sign and signature.

Handle events idempotently

The same event can arrive more than once (retries, manual resends). event_id (also the Idempotency-Key header) stays the same across retries.

js
async function handleEvent(event) {
  // Unique index on event_id makes duplicates a no-op
  const fresh = await db.htpEvents.insertIfAbsent({ eventId: event.event_id, type: event.event });
  if (!fresh) return;

  const o = event.data;
  switch (event.event) {
    case 'payment.paid':
      // Check it is the order you expect
      const order = await db.orders.findByNumber(o.out_order_no);
      if (!order || order.htpPaymentId !== o.id || order.total !== o.amount) return alertOps(event);
      await db.orders.markPaid(order.id, { paidAt: o.completed_at, payerSub: o.user_sub, net: o.net_amount });
      break;
    case 'payment.refunded':
      await db.refunds.markSucceeded(o.out_order_no, { paymentId: o.parent_id, amount: o.amount });
      break;
    case 'payout.completed':
      await db.payouts.markSucceeded(o.out_order_no);
      break;
  }
}

Also check that data.environment matches the environment you expect — a misconfigured sandbox callback URL should not fulfil real orders.

Responses and retries

  • Any 2xx acknowledges the event. Respond within 10 seconds; do slow work after responding.
  • Redirects are not followed — answer the configured URL directly.
  • Anything else (non-2xx, timeout, connection error) is retried after 1 min, 5 min, 30 min, 2 h and 6 h. After the last attempt the delivery is marked FAILED.
  • In the console, Callbacks lists every delivery with its attempts, last status and error, and Resend sends it again.

Callbacks are not your only source of truth

If your endpoint was down for longer than the retry window, recover by querying GET /open/v1/orders/{id} for orders still awaiting payment, and reconcile daily with the statement.

Rotating the callback secret

Rotate secret in the console returns a new secret and invalidates the old one immediately. To avoid failed deliveries, let your verifier accept either of two secrets, deploy the new one as secondary, rotate, then remove the old one. Failed deliveries during the switch are retried automatically.