# Webhooks

> Signed IPN events (charge.* and subscription.*), signature verification in 3 languages, retries and idempotent handling.

Set a webhook URL and an IPN secret on your connection. Fina then sends `POST` requests with `Content-Type: application/json` and the header `x-fina-sig` for each event.

### Charge events

- `charge.paid`
- `charge.expired`
- `charge.cancelled`
- `charge.refunded`

**charge.paid — JSON**

```json
{
  "event": "charge.paid",
  "charge_id": "550e8400-e29b-41d4-a716-446655440000",
  "reference": "order-42",
  "amount_minor": 150000,
  "fee_minor": 3750,
  "net_minor": 146250,
  "currency_code": "DOP",
  "status": "paid",
  "transfer_id": "8d2b…",
  "paid_at": "2026-10-05T14:03:11.000Z",
  "timestamp": "2026-10-05T14:03:12.000Z"
}
```

### Verify the signature

`x-fina-sig` is the hex **HMAC-SHA512** of the payload serialized as `JSON.stringify(payload, Object.keys(payload).sort())` — compact JSON whose keys follow the sorted list of top-level keys — keyed with your IPN secret. Note that it is computed over that re-serialization, not over the raw body bytes: parse the body, rebuild the string and compare in constant time. The Python and PHP helpers below produce exactly the same bytes as the JavaScript call.

**verifyFinaSignature — TypeScript**

```ts
import { createHmac, timingSafeEqual } from 'node:crypto'

/** Fina signs JSON.stringify(payload, sortedTopLevelKeys) with HMAC-SHA512. */
export function verifyFinaSignature(rawBody: string, signature: string, secret: string): boolean {
  const payload = JSON.parse(rawBody)
  const signed = JSON.stringify(payload, Object.keys(payload).sort())
  const expected = createHmac('sha512', secret).update(signed).digest('hex')
  return expected.length === signature.length && timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
}
```

**verifyFinaSignature — Python**

```python
import hashlib
import hmac
import json


def fina_signed_string(payload: dict) -> str:
    """Same bytes as JS JSON.stringify(payload, Object.keys(payload).sort())."""
    keys = sorted(payload.keys())

    def keep(value):
        if isinstance(value, dict):
            return {k: keep(value[k]) for k in keys if k in value}
        if isinstance(value, list):
            return [keep(v) for v in value]
        return value

    return json.dumps(keep(payload), separators=(",", ":"), ensure_ascii=False)


def verify_fina_signature(raw_body: bytes, signature: str, secret: str) -> bool:
    payload = json.loads(raw_body)
    expected = hmac.new(secret.encode(), fina_signed_string(payload).encode(), hashlib.sha512).hexdigest()
    return hmac.compare_digest(expected, signature or "")
```

**verifyFinaSignature — PHP**

```php
<?php
/** Same bytes as JS JSON.stringify(payload, Object.keys(payload).sort()). */
function fina_signed_string(object $payload): string
{
    $keys = array_keys(get_object_vars($payload));
    sort($keys, SORT_STRING);
    $keep = function ($value) use (&$keep, $keys) {
        if (is_object($value)) {
            $out = new stdClass();
            foreach ($keys as $k) {
                if (property_exists($value, $k)) $out->$k = $keep($value->$k);
            }
            return $out;
        }
        return is_array($value) ? array_map($keep, $value) : $value;
    };
    return json_encode($keep($payload), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}

function verify_fina_signature(string $rawBody, string $signature, string $secret): bool
{
    $payload = json_decode($rawBody); // objects, not arrays: keeps {} vs [] intact
    $expected = hash_hmac('sha512', fina_signed_string($payload), $secret);
    return hash_equals($expected, $signature);
}
```

### Delivery and retries

- Answer any `2xx` within **15 seconds** to acknowledge. Do slow work (emails, fulfilment) after responding.
- Anything else (timeout, `4xx`, `5xx`, network error) is retried after **1 min, 5 min, 30 min, 2 h and 24 h**; after that the delivery is marked failed.
- You can see recent deliveries and their status in the Fina Pay connector of your business.
- Events can arrive more than once and out of order. Make the handler idempotent (key on `charge_id` + `event`, or `event_id` for subscriptions) and, when in doubt, read the current state with `GET /charges/{id}`.

### Subscription events

Merchants using Fina Pay subscriptions also receive `subscription.created`, `subscription.trial_will_end`, `subscription.renewed`, `subscription.payment_failed`, `subscription.past_due`, `subscription.cancel_scheduled`, `subscription.reactivated`, `subscription.paused`, `subscription.resumed`, `subscription.plan_changed`, `subscription.canceled`, `subscription.expired` and `mandate.revoked`, with the same header, signature and retries. See [Subscriptions](https://fina.do/developers/subscriptions.md).

Source: https://fina.do/developers#webhooks