# Security

> How Paymos protects token balances: isolated withdrawal signing, destination whitelists, HMAC authentication, signed webhooks, SSRF controls, and audit trails.

Paymos is a custodial gateway — we hold the keys that move your funds on-chain. That puts a fair question in front of every merchant: **what stops Paymos from taking my money, and what happens if something breaks?**

This page answers both — first as a short list of promises, then in plain language with the reasoning behind each one. It describes the guarantees the platform gives you and what each one buys you, not an internal blueprint. Where a detail is part of the public API (how you sign a request, how you verify a webhook), we document it precisely, because you need it to integrate.

---

## Why your funds are safe

### Controlled destinations and isolated signing

- **Isolated signing infrastructure.** Paymos operates the custody and signing path away from the public web surface. This is managed custody, not direct merchant key control.
- **Whitelist on by default.** Funds leave only to destinations you approved in advance, and even a total compromise of your API credentials cannot invent a new payout address. The owner can have the whitelist switched off through support. From then on that protection is gone: whoever takes over the account or a payout key can send funds to an address of their own.
- **Owner-controlled by default.** The ability to withdraw, and the ability to edit the whitelist, are reserved for the account owner unless you explicitly delegate them — and every grant is logged.
- **You withdraw on your schedule.** Your balance is yours the moment a payment reaches finality. We do not lock funds. Withdraw hourly, daily, or never — nothing stands between the request and the transaction going out, and how long it then takes to land is the chain's own speed.
- **Segregated balances.** Each merchant's balance is tracked separately. We do not pool merchant funds, and we do not lend or rehypothecate them.

### Your money won't silently disappear

- **We credit at a depth we choose per network and amount** — deep enough that a reorg is rare, never deep enough to call it impossible.
- **No clawbacks.** Once we tell you a payment is confirmed, we never reverse it. If a confirmed payment is later dropped by a reorg, **Paymos absorbs the loss — not you.**
- **Payouts can't double-send.** Every withdrawal carries your own idempotency key.
- **Layered freeze controls** can halt a single asset automatically if on-chain reality drifts from the ledger.
- **Withdrawals are checked against your balance before they're accepted** — if your balance won't cover it, you get a clear error at once instead of a withdrawal that hangs.
- **Tamper-evident records**: every privileged action is audited, with secrets and signatures redacted before the record is written.

:::info
**No clawbacks.** Once we tell you a payment is confirmed, we never reverse it. If a confirmed payment is later dropped by a reorg, **Paymos absorbs the loss — not you.**
:::

The rest of this page explains each of these in plain terms. If you've seen the words HMAC or SSRF and wondered what they mean — read on.

---

## Threat model

We design against:

- **Funds at rest** — keys that could move balances on-chain
- **Funds in motion** — withdrawal authorization and destination tampering
- **API access** — credential theft, request replay, scope escalation
- **Webhook delivery** — forged payloads, and use of our infrastructure as an SSRF pivot into yours
- **Operational risk** — accidental destructive actions, multi-server race conditions, audit gaps

Explicitly out of scope: your customer's wallet security (they hold their own keys), recipient-side risk after a withdrawal signs (on-chain transactions are final), third-party services you run alongside Paymos, and social engineering of your team.

---

## Withdrawal signing and destination controls

**The problem.** Funds on a blockchain are controlled by signing keys. A stolen credential, a changed destination, or a signing-host compromise can therefore become a money-loss event unless the surrounding controls fail closed.

**What Paymos does.** Outgoing transactions are signed on isolated infrastructure. By default a withdrawal can go only to an address already present in the merchant's whitelist, and an empty whitelist disables withdrawals. Changing that whitelist is a separate, recorded action; once the merchant user has a second factor enrolled, the change also has to clear a fresh step-up challenge. An account with no second factor has nothing to challenge, so that last layer starts the day you enable it. A merchant whose owner had the whitelist switched off through support has no destination check at all — what is left for that case is set out under withdrawal protection below.

Operators can stop every outbound transfer or freeze one asset-and-network pair while an incident is investigated. Each withdrawal also carries an idempotency key, so replaying the same request does not create a second payout.

This is still **custodial** infrastructure: Paymos operates the signing path. Destination controls, freezes, audit records, and ledger reconciliation reduce risk around custody; they do not turn it into non-custodial or threshold custody.

Every invoice receives its **own unique deposit address**, so reconciliation is unambiguous. Sandbox payments and withdrawals do not touch live funds, so you can test the integration without creating production transfers.

---

## HMAC — how the API knows a request is really from you

**HMAC** stands for *Hash-based Message Authentication Code*. Scary name, simple idea.

**The problem.** Your server sends us "create a $1,000 withdrawal." We have two questions: is this really you, or an impostor? And was the request tampered with in transit — the amount or address changed? If you just put a password inside the request, anyone who intercepts the traffic steals it and sends requests as you.

**What HMAC does.** You and we share a **secret** (your API secret). For each request you compute a "fingerprint" (a signature) — a hash of *the request contents together with the secret*. You send the request **and the fingerprint, but not the secret**. We recompute the same fingerprint with our copy of the secret. If they match, it's really you, **and nothing was changed** — alter a single character and the fingerprint won't line up.

> **Analogy.** A wax seal on a letter. The secret is your unique stamp. Everyone can *see* the seal, but they can't **forge it without the stamp**. And if the letter is opened and rewritten, the seal no longer matches the contents.

The key point: the secret itself **never travels over the network**. So even recording all your traffic, an attacker can't extract the secret or craft a new request — they'd only hold signatures for requests you already sent. *(SHA-256 is the specific "mill" that grinds any input into a fixed-length fingerprint that can't be reversed.)*

### The exact format (for your integration)

Every authenticated request carries two headers:

```http
Authorization: HMAC-SHA256 {api_key_id}:{base64(signature)}
X-Request-Timestamp: {unix_seconds}
```

The signature is HMAC-SHA256 over a canonical string:

```text
{timestamp}\n{METHOD}\n{path}\n{query}\n{sha256_hex_lower(body)}
```

An empty body contributes an empty string in that slot — it is not hashed; query parameters are signed exactly as they appear in the URL. Full details on the [Authentication](/docs/authentication.md) page.

### Anti-replay protection

Could an attacker **resend an old request** they recorded — replay "create withdrawal" a hundred times? The timestamp is part of the signature, and we reject any request whose `X-Request-Timestamp` is more than **5 minutes** behind server time or more than **60 seconds** ahead of it (rejected with `401`). A recorded request lives five minutes, then dies; one signed with a clock running ahead lives at most a minute longer. Combined with idempotency (below), even within that window a replayed "create withdrawal" won't send funds twice.

> **Analogy.** A ticket with a five-minute entry window — yesterday's won't work.

### Constant-time comparison

When we compare two signatures, a naive comparison stops at the first wrong character — and the *response time* would reveal how many characters are already right, letting an attacker guess byte by byte. We compare in **constant time**: a "no" takes exactly the same time no matter how much you guessed.

> **Analogy.** A combination lock that says "no" in the same time whether you got one digit right or five. You can't tell you're getting "warmer."

### Key types and rotation

Two key types, each pinned to a fixed scope, enforced on the server for every call:

- **Payment key** — everything money-in: create and read invoices, and manage payment channels and read their deposits. Cannot read balances or create withdrawals.
- **Payout key** — create, read and cancel withdrawals, and read balances. Cannot create invoices or reach a payment channel.

Secrets rotate without downtime: you generate a new secret while the previous one stays valid until `previous_secret_expires_at`, always 24 hours after the rotation. The window is fixed, not a setting you pass. Both secrets are accepted until it closes, then only the new one works.

---

## Webhook security — the same HMAC, in reverse

HMAC above protects **your requests to us**. A webhook is **us knocking on your door** ("invoice paid"). Same trick, reversed: we sign every webhook with your endpoint secret, and you verify the signature before trusting it. Otherwise anyone who learns your webhook URL could send a fake "paid" event and trick you into shipping goods.

```http
X-Webhook-Signature: t={unix_seconds},v1={hex_hmac}
```

The signed payload is `{timestamp}.{request_body}`. The scheme matches Stripe's, so libraries that verify Stripe webhooks work here with almost no changes; during secret rotation we emit both signatures in the same header, and a secret that has leaked can be replaced without that overlap. See [Verifying Webhooks](/docs/webhooks/verify.md).

### SSRF protection

**SSRF** stands for *Server-Side Request Forgery*. You set a webhook URL, and **our servers** make requests to it. A clever attacker points the URL not outward but **inside our network** — at a cloud-metadata address or an internal panel — so our server fetches it on their behalf, turning Paymos into a proxy for things only our network can see.

We prevent that: a webhook destination is validated to resolve to a **public** address — rejecting loopback, private, internal, and cloud-metadata ranges — and the connection is pinned to that validated address so there's no DNS-rebinding window. Redirects are refused. A destination that fails validation is dropped immediately, so probing can't burn delivery budget. The port is checked as well: a webhook URL may name 443 or any port from 1024 up, so a delivery can't be aimed at the standard port of SSH, mail, or another well-known service on someone else's server.

> **Analogy.** A courier who only delivers to real external addresses, refuses to carry anything "to this building's own server room," checks the address before leaving, and ignores a note saying "actually, deliver it over there instead."

### Delivery semantics

Webhook URLs must use **HTTPS** (enforced when you save the endpoint). Delivery is **at-least-once** — deduplicate by event ID on your side (the convention Stripe, GitHub, and PayPal use). Failed deliveries are **retried** with exponential backoff across 11 attempts spanning roughly 16 hours — the full schedule is on [Delivery and retries](/docs/webhooks/retry.md) — then marked failed and replayable from the dashboard. Each attempt has its own timeout.

---

## Withdrawal protection — several locks in a row

Moving money out is where a custodial gateway lives or dies, so it has the most layers:

- **Whitelist (on by default).** While the whitelist is on, including while an opt-out request is pending, a withdrawal goes only to an address approved in advance. An empty list for the destination’s network family blocks the payout; making a first withdrawal does not add an address. Approved recipients can include your customers as well as your own wallets. A stolen Payout key cannot send to an unlisted address while this restriction is in force.
- **Switching the whitelist off.** Only the account owner can ask for it, from the Withdrawal Addresses card; support goes through the details in chat, and a person on the Paymos team makes the call. The whitelist remains in force while the request is pending. If an active Production Payout key exists, its IP allowlist must be non-empty before approval; an account without one can use dashboard payouts. Without the whitelist a payout goes to whatever address it names. In the dashboard you type that address and confirm with your second factor if you have one; through the API only a live Payout key with an IP allowlist can pay, and a key without one is refused with `ip_allowlist_required`. The cost is a layer of protection — whoever takes over the account or the key can send funds to an address of their own. The owner can turn the whitelist back on at any time from the Balance page, and the saved addresses return with it.
- **Roles and withdrawal permissions.** By default, Owner has permission to manage the whitelist, and Owner and Finance have permission to create withdrawals. Admin has neither permission by default; additional permissions can be granted separately, and grants are recorded. Requesting whitelist opt-out, cancelling that request and turning the whitelist back on remain owner-only actions: an extra permission does not delegate them.
- **Two-factor (2FA).** A six-digit code from an authenticator app, changing every 30 seconds; an enrolled passkey counts as the second factor too. It is off until you enrol it, and enrolling is what arms the step-up challenge: with a second factor on the account, changing the withdrawal whitelist, requesting it switched off, paying an address typed in by hand, editing an API key's IP allowlist, or turning the second factor off each demand a fresh confirmation at the moment they run.
- **Freeze controls (circuit breakers).** We can freeze everything at once, a single token on a single network, or a single merchant. A per-asset freeze can engage automatically if on-chain balances drift from the ledger beyond a safe margin.
- **Up-front balance check.** The amount and the network fee together are checked against your available balance before the withdrawal is accepted. If the two come to more than your balance holds, the API returns `insufficient_balance` on the spot and the withdrawal is never created — nothing is left half-done.
- **Idempotency.** Your order identifier is the idempotency key — send it twice and you get back the same single withdrawal, never a second transfer. *Analogy: a coat-check ticket; hand in the same ticket twice and you get the same one coat back.*
- **Race-safe creation.** Concurrent withdrawal requests for the same account are serialized server-side, so two parallel calls can't slip past the same balance check.

---

## Authorization & isolation

- **Every request is checked server-side** before it does anything: the environment must match (sandbox vs production), the caller type must be allowed, the credential's scope must cover the operation, and the resource must belong to the caller. These checks are the real boundary — UI controls are only hints.
- **404, not 403.** If a resource doesn't exist, *or* exists but isn't yours, you get a `404`. So an attacker can't tell "this ID is real but not yours" from "this ID doesn't exist." *Analogy: a doorman who says "no such person" whether they don't exist or live there but you're not allowed up.*
- **Your identity comes from your credential, never the request body.** Putting someone else's identifier in a request does nothing — you can't act on behalf of another merchant by spoofing an ID.
- **Granular permissions.** Access is governed by fine-grained, role-based permissions (e.g. withdraw, view balance, manage webhooks), each grantable independently. The server-side check is the boundary; the dashboard mirrors it.

---

## Rate limiting

Each merchant has its own rate limits. When you exceed one you receive `429 Too Many Requests` with a `Retry-After` header, so a single misbehaving integration can only affect its own throughput, not the platform or other merchants.

---

## On-chain finality — why your money won't disappear

On a blockchain a transaction "appears" in a block quickly, but the chain can still **rewrite** recent history (a "reorg") — and something that looked confirmed can vanish. Like wet ink: written, but it can still smudge.

**Finality** means waiting until the transaction is buried deep enough that it can't be reversed. The ink has dried. We wait for each network's appropriate finality before moving your balance. On chains where finality is a matter of confirmation depth, that depth scales with the amount — small tickets clear fast, large ones wait longer; chains with native instant finality settle as soon as the block is final. These thresholds are tuned to network conditions, not fixed.

The main promise: if a payment we already confirmed is later dropped by a reorg (very rare), **Paymos absorbs the loss, not you.** Once a confirmation webhook fires, you can act on it immediately. As a bonus, there are **no chargebacks** (unlike cards, which a buyer can reverse for months after the sale). An expired invoice also stays expired: money that arrives after the deadline does not reopen it. If late transfers in the invoice's token, together with what came before, cover the amount, you receive `invoice.paid_late` with the status unchanged, and whether to fulfil the order is your call.

---

## Data protection

**In transit:** all traffic is TLS 1.2+, HSTS is enabled in production, and un-encrypted requests are redirected to HTTPS.

**At rest:** session cookies are `HttpOnly`, `Secure`, `SameSite=Lax`; sign-in links are single-use, expire quickly, and are encrypted at rest.

**Anti-forgery:** the server accepts sign-in and session requests only from the Paymos site's own origin, and dashboard actions that change state carry an anti-forgery token; `SameSite=Lax` cookies add one more layer against cross-site request forgery.

**Audit log:** every privileged action records who did it, what ran, the outcome, and when. Sensitive values — secrets, passwords, tokens, hashes, signatures — are redacted before the record is stored, enforced at write time.

---

## Operational resilience

- **Multi-server safety.** Running across several servers never double-processes a payment, withdrawal, or webhook — concurrent work on the same item is coordinated to happen exactly once. Verified by automated tests, not assumed.
- **Privacy-safe error reporting.** Crash reports are scrubbed of personal data and request contents before they leave our systems.
- **Self-healing infrastructure.** Automated health checks pull an unhealthy instance out of rotation.
- **Safe schema changes.** Database migrations are applied once, in order, and are forward-only — we never run destructive rollbacks against production data.

---

## Compliance & data handling

**No KYC.** You can register and begin testing without submitting identity, address, or beneficial-owner documents, and Paymos does not collect those documents from your customers. The product has no KYC or KYB flow — no document upload, no questionnaire, no approval gate in front of Production. Paymos does not vet your business, check licences, or screen merchant categories. What it does check, automatically, is blockchain addresses: where incoming payments come from and where withdrawals go. That is an address check, not an identity check of your customers. AML and sanctions obligations stay with the merchant; our [AML policy](/aml) describes the check and where the line falls.

**Customer-side data.** By design, Paymos collects no personal data from your customers at checkout — no email, phone, name, or card data. The flow is invoice → payment page → wallet pays → done. We see wallet addresses and amounts, not identities, so your GDPR/CCPA exposure through Paymos is effectively zero.

**Merchant-side data.** For your account we hold business name, contact email, country, and project metadata (webhook URLs, API key identifiers). A GDPR deletion request triggers a 30-day soft delete followed by permanent purge; the audit trail retains only a hash of the deleted record.

**Data residency.** Primary data is stored in the EU. Enterprise contracts can pin a specific region — contact us.

---

## What we don't do

- **We don't claim things we haven't built.** This page describes the system as deployed. Roadmap items aren't listed until they ship.
- **We don't custody longer than needed.** Funds reach your balance the moment a payment confirms; withdrawal timing is your choice.
- **We don't pool merchant balances.** Each balance is segregated. One merchant's insolvency or dispute can't touch another's funds. *Pooling a shared pot is exactly what sank exchanges like FTX.*
- **We don't promise recipient-side safety.** Once a withdrawal signs, the transaction is on-chain and final. While it is on, the whitelist prevents typos and key theft — it can't validate that a destination you approved will behave.

---

## Reporting a vulnerability

Send disclosures to **security@paymos.io**.

- **Acknowledgement:** within two business days of a complete report
- **Scope:** production `paymos.io` and `*.paymos.io`, the production REST API, and Paymos-published SDKs and CMS plugins
- **Out of scope:** rate-limit-based denial of service, social engineering, third-party services (your CDN, hosting provider, etc.)
- **Bug bounty:** no public program currently; private disclosure of material findings is rewarded case-by-case, with acknowledgment on request.

Please don't test against other merchants' accounts, use destructive testing against production, or disclose publicly before we've had a chance to fix. We commit to acknowledge a complete report within two business days, keep you posted on the fix, credit you (with your permission) when it ships, and not pursue legal action against good-faith research within scope.
