# Webhooks

Coldtill sends an HTTP `POST` to your endpoints whenever a payment or payout changes. Add endpoints under
**Webhooks** in the dashboard; each one gets its own signing secret, shown once.

## Delivery

- **Method and body:** `POST` with a JSON body.
- **Success:** any `2xx` response within 60 seconds. Redirects are not followed.
- **Destination:** on mainnet, endpoints must be public `https` URLs. Private, loopback, link-local,
  carrier-grade NAT and other reserved addresses are refused when connecting, so DNS tricks don't help.
- **Ordering:** not guaranteed. Use the payload's `status` and `timestamp`, and treat events as idempotent.

Headers:

| Header | Value |
|---|---|
| `X-Coldtill-Event` | Event name, for example `payment.filled` |
| `X-Coldtill-Delivery` | Unique delivery ID. Retries of the same delivery reuse it |
| `X-Coldtill-Timestamp` | Unix time in seconds when this attempt was signed |
| `X-Coldtill-Signature-V2` | `sha256=` + hex HMAC-SHA256 of `<timestamp>.<raw body>` with the endpoint secret |
| `X-Coldtill-Signature` | `sha256=` + hex HMAC-SHA256 of the raw body (legacy, no replay protection) |
| `User-Agent` | `Coldtill-Webhook/1` |

Coldtill was called Keyhold before. Every header above is also sent under its old `X-Keyhold-` name with
the same value, so receivers written before the rename keep working. New code should read `X-Coldtill-`.

## Retries

| Kind | Schedule after the first attempt |
|---|---|
| Progress events | 2 s, 4 s. A later change sends a fresh event anyway |
| Final events | 2 s, 4 s, 30 min, 1 h, 2 h, 4 h, 8 h, 24 h, 48 h, then marked failed |

You can retry any delivery by hand under **Webhooks → Deliveries**.

## Verifying requests

Always verify before trusting the body:

1. Read the **raw** body bytes, before any JSON parsing.
2. Check that `X-Coldtill-Timestamp` is within five minutes of your clock.
3. Compute `HMAC-SHA256(secret, timestamp + "." + rawBody)`, hex-encode it, prefix `sha256=`.
4. Compare with `X-Coldtill-Signature-V2` in constant time.
5. Optionally, store `X-Coldtill-Delivery` IDs you've processed and ignore repeats.

`X-Coldtill-Event` and `X-Coldtill-Delivery` are not signed. Decide what to do from the signed body (its
`status`, and `reference_id` for payments), not from the event header. The SDK derives the event from the
body and rejects a request whose header disagrees.

### Node.js (SDK)

```ts
import express from 'express'
import { verifyWebhookRequest, isPaid } from '@coldtill/sdk'

const app = express()
app.post('/coldtill/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  try {
    const ev = await verifyWebhookRequest(req.body, req.headers, process.env.COLDTILL_WEBHOOK_SECRET!)
    if (ev.event.startsWith('payment.') && isPaid(ev.data.status)) {
      await markOrderPaid(ev.data.invoice_id, ev.data.reference_id)
    }
    res.sendStatus(200)
  } catch {
    res.sendStatus(401)
  }
})
```

### PHP

```php
$raw = file_get_contents('php://input');
$ts  = $_SERVER['HTTP_X_COLDTILL_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_COLDTILL_SIGNATURE_V2'] ?? '';
$ok  = ctype_digit($ts) && abs(time() - (int) $ts) <= 300
    && hash_equals('sha256=' . hash_hmac('sha256', $ts . '.' . $raw, $secret), strtolower($sig));
if (!$ok) { http_response_code(401); exit; }
$event = json_decode($raw, true);
```

### Go

```go
func verify(r *http.Request, body, secret []byte) bool {
	ts := r.Header.Get("X-Coldtill-Timestamp")
	n, err := strconv.ParseInt(ts, 10, 64)
	if err != nil || math.Abs(float64(time.Now().Unix()-n)) > 300 {
		return false
	}
	m := hmac.New(sha256.New, secret)
	m.Write([]byte(ts + "."))
	m.Write(body)
	want := "sha256=" + hex.EncodeToString(m.Sum(nil))
	return hmac.Equal([]byte(want), []byte(strings.ToLower(r.Header.Get("X-Coldtill-Signature-V2"))))
}
```

### Python

```python
import hashlib, hmac, time

def verify(headers, raw: bytes, secret: bytes) -> bool:
    ts = headers.get("X-Coldtill-Timestamp", "")
    if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        return False
    want = "sha256=" + hmac.new(secret, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, headers.get("X-Coldtill-Signature-V2", "").lower())
```

## Payment events

Event names are `payment.` plus the lower-cased state: `payment.open`, `payment.partially_filled`,
`payment.filled`, `payment.over_filled`, `payment.cancelled`, `payment.replaced`.

```json
{
  "customer_id": "42",
  "invoice_id": "INV-1001",
  "reference_id": "fd09c7e5-d661-4355-9b04-7b3749b41e2e",
  "status": "FILLED",
  "amount": "25",
  "currency": "USD",
  "filled_amount": "25.00000000",
  "filled_amount_in_usd": "25.00000000",
  "timestamp": 1791647937,
  "payment_info": [
    {
      "source_address": "0x…", "transaction_hash": "0x…", "destination_address": "0x…",
      "block_number": 21234567, "network": "BASE", "currency": "USDC", "amount": "25"
    }
  ],
  "confirmation_current": 0,
  "confirmation_required": 0
}
```

- **Progress events** (a deposit seen or gaining confirmations) include `confirmation_current` and
  `confirmation_required` for the deposit that triggered them.
- **The final event** (`filled`, `over_filled`, `cancelled`, `replaced`) is sent once, when the payment closes.
  A late deposit on a closed payment sends a progress event with the new `filled_amount`.
- **Auto-convert:** payments paid through a swap include a `conversion` array:

  ```json
  "conversion": [{
    "provider": "near-intents", "from_currency": "BTC", "from_network": "BTC", "from_amount": "0.000253",
    "from_amount_in_usd": "15.18", "network": "BASE", "currency": "USDC", "amount": "15",
    "transaction_hashes": ["0x…"]
  }]
  ```

## Payout events

Event names are `payout.` plus the status: `payout.pending-approval`, `payout.pending`, `payout.initiated`,
`payout.sent`, `payout.failed`, `payout.rejected`, `payout.cancelled`, `payout.needs-review`. The body is the
[payout object](https://coldtill.com/docs/api.md#payout-object).

`payout.needs-review` means Coldtill could not tell whether the transaction landed. Don't retry the payout
until an admin has checked the hot wallet's history and resolved it. See [Payouts](https://coldtill.com/docs/payouts.md#needs-review).

## Testing

**Webhooks → Send test** posts a signed test event to an endpoint. On testnet installs you can point
endpoints at `http://localhost` receivers; mainnet requires public `https` URLs.

---

Source: https://coldtill.com/docs/webhooks/ · All Coldtill docs: https://coldtill.com/llms.txt
