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
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…={
"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:
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=1above;/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.
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)
});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 "", 200func 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
$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);// 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.
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.