Webhooks

Receive a signed HTTP callback the moment an invoice is paid, an invoice expires, or a payment settles — so you can react in real time instead of polling.

A webhook is an HTTPS POST that Markgroup sends to a URL you control whenever a subscribed event happens. Each request is signed with a per-endpoint secret so you can verify it genuinely came from Markgroup and has not been tampered with or replayed.

Set up an endpoint

Webhook endpoints are managed in your dashboard, not through the public API:

  1. Open Business → Developers in the Markgroup app.
  2. Under Webhook endpoints, choose Add endpoint and enter your HTTPS URL (plain http is rejected).
  3. Select the events you want delivered.
  4. Copy the signing secret (whsec_…) that is shown once — you will use it to verify every delivery. Store it as a server-side secret.

The signing secret is shown only once

For your security the full whsec_… secret is displayed a single time at creation and never again. If you lose it, delete the endpoint and create a new one.

Event catalog

Only the events below exist today. Subscribe to any combination when you create the endpoint.

EventWhen it fires
invoice.paidAn invoice was fully paid. Fulfil the order or unlock the goods.
invoice.expiredAn invoice passed its expiry window without being paid. Release any held stock.
payment.receivedA payment settled on-chain and was credited to your balance.

Delivery format

Every delivery is a JSON body with a stable envelope, plus two headers that identify and sign it:

  • X-Merchant-Event — the event type, e.g. invoice.paid.
  • X-Merchant-Signature — t=<unix-seconds>,v1=<hex-hmac-sha256>.

The body carries the event name, a data object (the same shape you get from the matching API resource), and a unique delivery_id you can use for idempotency:

Example delivery body
{
  "event": "invoice.paid",
  "data": {
    "id": "inv_3n8Kd0Qz",
    "object": "invoice",
    "status": "paid",
    "amount": 50000,
    "asset": "BTC",
    "deposit_address": "bc1q…",
    "created_at": "2026-01-14T09:12:00Z"
  },
  "delivery_id": "whd_9Fk2Pq7Za1"
}

Verify the signature

Compute an HMAC-SHA256 of <timestamp>.<raw-body>using your endpoint's signing secret and compare it — in constant time — to the v1 value. Reject the request if the timestamp is more than 5 minutes (300s) from now, which stops replayed deliveries.

verify.js
import { createHmac, timingSafeEqual } from "node:crypto"

// The signing secret shown once when you created the endpoint (whsec_…).
const SIGNING_SECRET = process.env.MG_WEBHOOK_SECRET

// Header format: X-Merchant-Signature: t=<unix-seconds>,v1=<hex-hmac-sha256>
export function verifyMerchantSignature(header, rawBody, toleranceSeconds = 300) {
  if (!header) return false
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("=").map((s) => s.trim())),
  )
  const ts = Number(parts.t)
  if (!Number.isFinite(ts)) return false
  // Reject stale timestamps to blunt replay attacks.
  if (Math.abs(Math.floor(Date.now() / 1000) - ts) > toleranceSeconds) return false

  const expected = createHmac("sha256", SIGNING_SECRET)
    .update(`${ts}.${rawBody}`)
    .digest("hex")

  const a = Buffer.from(expected, "hex")
  const b = Buffer.from(parts.v1 ?? "", "hex")
  return a.length === b.length && timingSafeEqual(a, b)
}

Verify the raw body

Compute the signature over the exact bytes you received, before JSON parsing or any framework middleware re-serializes them. Re-stringifying parsed JSON can reorder keys and break verification.

Respond and stay idempotent

  • Return any 2xx status to acknowledge receipt. Respond quickly — requests time out after 10 seconds, and a timeout counts as a failed attempt.
  • Do your slow work asynchronously; acknowledge first, then process.
  • Deduplicate on delivery_id. A delivery can arrive more than once (for example after a retry), so processing must be safe to repeat.
Express handler
import express from "express"
import { verifyMerchantSignature } from "./verify.js"

const app = express()

// IMPORTANT: verify over the RAW body, before any JSON parsing.
app.post("/webhooks/markgroup", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8")
  const ok = verifyMerchantSignature(req.header("X-Merchant-Signature"), raw)
  if (!ok) return res.status(400).send("bad signature")

  const { event, data, delivery_id } = JSON.parse(raw)

  // Idempotency: ignore a delivery_id you have already processed.
  if (alreadyProcessed(delivery_id)) return res.status(200).send("ok")

  switch (event) {
    case "invoice.paid":
      fulfilOrder(data.id)
      break
    case "invoice.expired":
      releaseStock(data.id)
      break
    case "payment.received":
      creditLedger(data.id)
      break
  }

  markProcessed(delivery_id)
  // Respond 2xx quickly. Anything else (or a timeout) triggers a retry.
  res.status(200).send("ok")
})

Retries & failure handling

A delivery is successful only when your endpoint returns a 2xx. Any other status, a connection error, or a timeout is retried with exponential backoff — roughly doubling each time and capped at 6 hours between attempts — until the maximum attempt count is reached, after which the delivery is marked failed and not retried again.

  • Disabling or deleting an endpoint stops further attempts for its pending deliveries.
  • You can review recent attempts, their HTTP status, and errors under Business → Developers.

Security checklist

  • Always verify the signature; never trust the body alone.
  • Enforce the timestamp tolerance to reject replays.
  • Serve your endpoint over HTTPS only.
  • Keep the signing secret server-side; never ship it to a browser or mobile client.
  • Treat the webhook as a fast signal, then confirm critical state with a read call before releasing high-value goods.

Prefer to poll?

Webhooks are optional. You can achieve the same reconciliation by polling — see Reconcile payments.