# HarborPay API

HarborPay sends USD payouts over SWIFT from your prefunded balance to bank accounts in the United States. This is the sandbox: it behaves like production, moves no real money and uses test credentials.

Base URL: `/harborpay/v1` on the host that served these docs. The same content is available as [OpenAPI 3.1](/docs/harborpay/openapi.json) and as [Markdown](/docs/harborpay.md).

## Getting started

A payout needs three resources, created in this order:

1. **Linked account**: the customer you pay out on behalf of.
2. **Beneficiary**: a bank account that belongs to that linked account. It becomes `ACTIVE` a few seconds after you create it.
3. **Payout**: an amount sent from your balance to the beneficiary.

```bash
curl -X POST "$HARBORPAY_BASE_URL/payouts" \
  -H "X-Api-Key: $HARBORPAY_API_KEY" \
  -H "Idempotency-Key: 3f1c9a52-6f0e-4d7b-9b1e-2a4c8d7e6f10" \
  -H "Content-Type: application/json" \
  -d '{
    "reference": "ord_1042-a1",
    "linkedAccountId": "hp_la_01J8Z3K4Q2V7XH5N9M6T0P1R2S",
    "beneficiaryId": "hp_ben_01J8Z3K4Q2V7XH5N9M6T0P1R2S",
    "amount": { "minorUnits": "15000", "currency": "USD" },
    "paymentMethod": "SWIFT"
  }'
```

HarborPay answers `202 Accepted` with the payout at `QUEUED`. That means HarborPay has taken the payout on, not that the money has arrived. Outcomes reach you as webhooks at the endpoint you set (see Configure your webhook endpoint).

## Authentication

Send your API key in the `X-Api-Key` header on every request. Sandbox keys look like `hp_test_sk_` followed by 32 letters and digits. Each account has one key, and it doesn't expire.

A missing, wrong, rotated or revoked key gets `401 UNAUTHORIZED`. When a key is rotated, the old one stops working immediately.

## Configure your webhook endpoint

HarborPay sends webhooks to one endpoint per account, which you set:

```bash
curl -X PUT "$HARBORPAY_BASE_URL/webhook-endpoint" \
  -H "X-Api-Key: $HARBORPAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://your-devbox.example.dev/webhooks/harborpay" }'
```

- `PUT /webhook-endpoint` with `{ "url" }` sets or replaces it and answers `200 { "url", "updatedAt" }`. Repeating the same request is safe, so it takes no Idempotency-Key. `GET /webhook-endpoint` returns the same object, and `GET /account` shows the URL as `webhookUrl`. A change applies from the next attempt.
- The URL must be `http` or `https`, without credentials or a fragment; anything else gets `400 INVALID_REQUEST` at `url`.
- The sandbox only delivers to approved development hosts. Another host gets `422 WEBHOOK_URL_NOT_ALLOWED`, and `details.allowedOrigins` lists the accepted origins, where `*` stands for any run of characters in the host or port.
- Until you set one, `url` and `updatedAt` are `null` and HarborPay sends no webhooks. Payouts still change status, and `GET /payouts/{id}` shows it; changes from that time are not sent later, so read them with `GET`.
- `POST /webhooks/test` sends a signed `ping` to check it (see Test webhooks); it gets `422 WEBHOOK_URL_NOT_SET` until an endpoint is set.

## Conventions

- **Format.** Requests and responses are JSON in UTF-8. Request bodies larger than 32 KiB are rejected with `413 PAYLOAD_TOO_LARGE`.
- **Strict requests.** An unknown, missing or mistyped field, or a value that breaks a rule, gets `400 INVALID_REQUEST`. `details.errors` lists the failing paths, each with a message.
- **Tolerant responses.** HarborPay adds fields, event types and reason codes over time. Ignore anything you don't recognise instead of failing on it.
- **Timestamps** are ISO 8601 in UTC with milliseconds, for example `2026-09-21T14:13:20.000Z`.
- **Ids** are a type prefix followed by a 26-character ULID (`hp_po_01J8Z3K4Q2V7XH5N9M6T0P1R2S`). Treat them as opaque strings.
- **Request ids.** Every response carries an `X-Request-Id` header (`req_…`), except a gateway `504`. Quote it when you contact support.
- **Lists** take `?cursor=` and `?limit=` (1–100, default 50) and return `{ "data": [...], "nextCursor": "…" | null }`, oldest first. Pass `nextCursor` back as `cursor` for the next page; a malformed cursor is a `400`. Filters are exact matches.
- **Your account only.** Resources belong to one sandbox account. An id from another account is a `404`.
- **Read-after-write.** Reads can lag behind a successful create by a few seconds. A resource can be used in the next request as soon as its create returns; only GET and list results can lag.
- **Capacity.** A sandbox account can create up to 5,000 resources; after that, creates get `403 SANDBOX_CAPACITY`.
- **Timeouts.** Most requests are answered within a second. If you set a client timeout on creates, allow at least 10 seconds and treat a timeout like a 5xx.

## Errors and retries

Errors share one envelope:

```json
{
  "code": "INVALID_REQUEST",
  "message": "The request is invalid; see details.",
  "details": { "errors": [{ "path": "amount.minorUnits", "message": "Required" }] },
  "requestId": "req_01J8Z3K4Q2V7XH5N9M6T0P1R2S"
}
```

`details` is `{}` when there is nothing to add.

| Status | Code | Meaning | Was anything created? | What to do |
|---|---|---|---|---|
| 400 | `INVALID_REQUEST` | The request is invalid; `details.errors` lists the paths | No | Fix the request. The Idempotency-Key is not used up. |
| 401 | `UNAUTHORIZED` | The API key is missing, invalid, rotated or revoked | No | Check the key. |
| 403 | `ACCOUNT_DISABLED` | Your account can't create payouts right now | No | Don't retry automatically. Reads still work. |
| 403 | `SANDBOX_CAPACITY` | The account has created as many resources as the sandbox allows | No | |
| 404 | `NOT_FOUND` | Not in your account | n/a | After a create with an unknown outcome, a 404 proves nothing: reads can lag. |
| 409 | `IDEMPOTENCY_CONFLICT` | The Idempotency-Key was already used with a different body | No; the first result stands | Don't retry blindly. It points to a bug or a concurrent writer. |
| 413 | `PAYLOAD_TOO_LARGE` | The body is larger than 32 KiB | No | |
| 422 | see Business rejections | HarborPay understood the request and declined it | No | Don't resend it unchanged; decide what to do. If you sent an Idempotency-Key, the response is stored against it. |
| 422 | `WEBHOOK_URL_NOT_ALLOWED` · `WEBHOOK_URL_NOT_SET` | Your webhook endpoint isn't on an approved host, or isn't set yet | No | See Configure your webhook endpoint. |
| 429 | `RATE_LIMITED` | Too many requests | No | Wait `Retry-After` seconds, then retry (with the same key, if you sent one). |
| 500 | `INTERNAL_ERROR` | Unexpected error | Unknown | Retry with the same key and backoff, or look the payout up by `reference`. Without a key, a retry is a new request. |
| 503 | `SERVICE_UNAVAILABLE` | Temporarily unavailable; comes with `Retry-After` | Unknown | As for 500. |
| 504 | (none) | Gateway timeout | Unknown | As for 500. The body may not be JSON. |

A timeout or a dropped connection is an unknown outcome too. In every unknown case, retry with the **same** Idempotency-Key (with backoff) or look the payout up with `GET /payouts?reference=`. Don't switch to a new key until you know what happened. Without a key, HarborPay can't tell a retry from a new request, and each one creates a new resource. Errors from the network in front of HarborPay may not be JSON; don't assume a parseable body.

## Endpoints

The three creates take an optional `Idempotency-Key` header (see Idempotency). `POST /webhooks/test` and `PUT /webhook-endpoint` take none.

| Endpoint | Success | Notes |
|---|---|---|
| `GET /account` | `200` | `{ id, environment: "sandbox", currency: "USD", funding: "PREFUNDED", webhookUrl }`; `webhookUrl` is `null` until you set an endpoint |
| `GET /webhook-endpoint` · `PUT /webhook-endpoint` | `200` | `{ url, updatedAt }`; see Configure your webhook endpoint |
| `POST /linked-accounts` | `201` | |
| `GET /linked-accounts/{id}` · `GET /linked-accounts?customerReference=` | `200` | |
| `POST /beneficiaries` | `201` | `422 INVALID_RESOURCE` if the linked account doesn't exist |
| `GET /beneficiaries/{id}` · `GET /beneficiaries?reference=` | `200` | |
| `POST /payouts` | `202` | Business rejections below |
| `GET /payouts/{id}` · `GET /payouts?reference=` | `200` | References are not unique, so the filter can return several payouts |
| `POST /webhooks/test` | `202` | `{ eventId }`; sends a signed `ping`; `422 WEBHOOK_URL_NOT_SET` until you set an endpoint |

## Resources and fields

Lengths count UTF-16 code units, as JavaScript's `length` does, so an emoji can count as two. A *reference* is 1–80 letters, digits, `_` or `-`.

**Linked account.** Request: `customerReference` (a reference; your customer's id, not unique) and `displayName` (1–140). HarborPay adds `id` (`hp_la_…`), `status: "ACTIVE"`, `currency: "USD"`, `createdAt` and `updatedAt`. Linked accounts are active as soon as they are created.

**Beneficiary.** Request:

- `reference` (a reference), `linkedAccountId`, `name` (1–140)
- `address`: `line1` (1–35), optional `line2` (1–35), `city` (1–35), `postalCode` (1–16), `country: "US"`
- `bankAccount`: `accountNumber` (4–17 digits), `bic` (a US BIC: 8 or 11 characters with `US` as the country code, e.g. `TSTBUS33`), `bankCountry: "US"`

HarborPay adds `id` (`hp_ben_…`), `status`, `createdAt` and `updatedAt`. Beneficiaries are immutable: new bank details need a new beneficiary.

A new beneficiary starts `PENDING` while HarborPay verifies its bank details, which usually takes a few seconds, and then becomes `ACTIVE`. Check that `GET /beneficiaries/{id}` shows `ACTIVE` before paying out to it: a payout to a `PENDING` beneficiary gets `422 BENEFICIARY_NOT_ACTIVE`.

**Payout.** Request:

- `reference`: your order or attempt id (a reference, not unique)
- `linkedAccountId`, `beneficiaryId`
- `amount`: `{ "minorUnits": "15000", "currency": "USD" }`
- `paymentMethod: "SWIFT"`

## Money

Amounts are objects with an integer string of cents: `{ "minorUnits": "15000", "currency": "USD" }` is USD 150.00. `minorUnits` has no sign, no decimal point and no leading zeros. Limit values in error details (`minimumMinor`, `maximumMinor`, `requestedMinor`) are integer strings too.

## Payout limits

Each payout must be within your account's per-payout minimum and maximum. The limits are set for your account, aren't published, and can change at any time. A change applies to requests received after it; payouts already accepted aren't re-checked. A request outside the current limits is rejected with `422 AMOUNT_BELOW_MINIMUM` or `422 AMOUNT_ABOVE_MAXIMUM`, and its `details` contain the current limits (inclusive) and the amount you asked for. There are no fees in the sandbox, so limits apply to the amount as sent.

For HarborPay, the message reads "Amount is below your account's current minimum of USD {minimum}." (or "above … maximum"), and `details` carries `minimumMinor`, `maximumMinor` and `requestedMinor`.

## Idempotency

- **Optional.** HarborPay uses the `Idempotency-Key` header to recognise a retry of the same request. Without it, HarborPay can't tell a retry from a new request, and each one creates a new resource.
- **Format and scope.** 1–255 printable ASCII characters without spaces, scoped to your account and the endpoint. Use a new key, such as a UUID, per logical request.
- **What is stored.** For 24 hours from the first request with a key, HarborPay stores every `201`/`202` and every `422` (including `DAILY_LIMIT_EXCEEDED`). Nothing is stored for `400`, `401`, `403`, `409`, `413`, `429` or `5xx`, so retrying those with the same key is safe. Nothing is stored for a request without a key.
- **Same key, same body.** You get the stored status and body again, with `Idempotency-Replayed: true`, even if your account was disabled or your limits changed since. Bodies are compared as JSON, so key order and whitespace don't matter. The body is the snapshot from the first response: a payout that has since settled still replays as `QUEUED`. Use `GET /payouts/{id}` for the current state.
- **Same key, different body.** `409 IDEMPOTENCY_CONFLICT`, and nothing changes: "This Idempotency-Key was already used for a different request (stored response: 422). Use a new key for a new request."
- **Concurrent requests** with the same key wait for the first one and receive its stored result.
- **After 24 hours** the key is forgotten and can create again.

## Payout lifecycle

- `QUEUED` → `PROCESSING` → `SETTLED` → `RETURNED`
- `PROCESSING` → `FAILED`
- `PROCESSING` → `IN_REVIEW` → `SETTLED` or `FAILED`

| Status | Meaning | Final? | `statusReason` | Webhook |
|---|---|---|---|---|
| `QUEUED` | Accepted, not yet sent | No | `null` | none: the `202` is the acknowledgement |
| `PROCESSING` | Sent into SWIFT; `uetr` is assigned | No | `null` | `payout.processing` |
| `IN_REVIEW` | Held for compliance review, usually after `PROCESSING`; ends `SETTLED` or `FAILED` | No | `COMPLIANCE_REVIEW` | `payout.in_review` |
| `SETTLED` | The beneficiary's bank accepted the funds (crediting the account can take longer) | Unless returned | `null` | `payout.settled` |
| `FAILED` | Stopped before money left your balance | Yes | a failure code | `payout.failed` |
| `RETURNED` | Sent, then returned by the beneficiary's bank; the full amount is credited back to you | Yes | a return code | `payout.returned` |

`SETTLED` is not always the end: a settled payout can still become `RETURNED`, at a higher version.

**The payout object:** `id` (`hp_po_…`), `reference`, `linkedAccountId`, `beneficiaryId`, `amount`, `paymentMethod`, `status`, `statusVersion`, `statusReason` (`{ code, message }` or `null`), `uetr` (the SWIFT UETR, a UUID v4, from `PROCESSING`; else `null`), `settledAt`, `returnedAt`, `createdAt`, `updatedAt`.

### Versions

`statusVersion` is `1` when the payout is created and goes up by one with every status change. `GET` always returns the latest version, and every webhook carries a complete payout snapshot with its version.

Webhooks can arrive late, more than once, out of order (a newer snapshot before an older one) or not at all. Apply this rule:

- **Apply a snapshot only if its `statusVersion` is greater than the one you have stored.** Otherwise acknowledge it with a `2xx` and ignore it.
- A jump, say from 2 to 4, means you missed a notification. The snapshot is still complete, so apply it.
- If in doubt, `GET /payouts/{id}`.

### Reason codes

| Code | Status | Meaning |
|---|---|---|
| `COMPLIANCE_REVIEW` | `IN_REVIEW` | HarborPay's compliance team is reviewing this payout. |
| `BENEFICIARY_ACCOUNT_CLOSED` | `FAILED`, `RETURNED` | The beneficiary's bank reports the account is closed. |
| `COMPLIANCE_REJECTED` | `FAILED` | HarborPay's compliance review declined this payout. |
| `BANK_REJECTED` | `FAILED` | The beneficiary's bank or an intermediary rejected the payment without a specific reason. |
| `BENEFICIARY_NAME_MISMATCH` | `RETURNED` | The beneficiary's bank couldn't match the name to the account. |
| `RETURNED_BY_BENEFICIARY_BANK` | `RETURNED` | The beneficiary's bank sent the funds back without a specific reason. |

This list is not exhaustive: treat a code you don't recognise as needing investigation. Messages for `RETURNED` read "The beneficiary's bank returned the funds: …" followed by the meaning.

## Business rejections

A `422` means HarborPay understood the request and declined it. Nothing was created, and if you sent an Idempotency-Key, the response is stored against it.

| Code | When | `details` |
|---|---|---|
| `INVALID_RESOURCE` | The linked account or beneficiary doesn't exist | `{ "field": "beneficiaryId" }` |
| `ACCOUNT_MISMATCH` | The beneficiary belongs to a different linked account | `{ "beneficiaryId", "linkedAccountId", "beneficiaryLinkedAccountId" }` |
| `BENEFICIARY_NOT_ACTIVE` | The beneficiary is still `PENDING` | `{ "beneficiaryId", "status" }` |
| `AMOUNT_BELOW_MINIMUM` · `AMOUNT_ABOVE_MAXIMUM` | The amount is outside your current limits | `{ "minimumMinor": "<minimum>", "maximumMinor": "<maximum>", "requestedMinor": "<amount>" }` |
| `DAILY_LIMIT_EXCEEDED` | The payout would take the linked account over its daily limit, which resets at 00:00 UTC | `{ "linkedAccountId", "resetsAt" }` |

## Webhooks

HarborPay sends a webhook for every status change after creation to your account's webhook endpoint (see Configure your webhook endpoint); a change of endpoint applies from the next attempt. While no endpoint is set, it sends none.

### Delivery

- `POST` with a JSON body, a 5-second timeout and no redirects followed. Only a `2xx` counts as received.
- The first attempt is immediate. Retries follow after 5 s, 15 s, 45 s, 2 min and 6 min: six attempts over about nine minutes.
- HarborPay retries after any non-`2xx` response (including `3xx`), a timeout or a connection error.
- A redelivery sends the event's original bytes with the same event id, a fresh timestamp and a fresh signature.

### Body

```json
{
  "id": "evt_01J8Z3K4Q2V7XH5N9M6T0P1R2S",
  "type": "payout.settled",
  "createdAt": "2026-09-21T14:13:20.000Z",
  "data": { "id": "hp_po_…", "status": "SETTLED", "statusVersion": 3, "…": "the full payout" }
}
```

Types: `payout.processing`, `payout.in_review`, `payout.settled`, `payout.failed`, `payout.returned`, and `ping` (with `data: {}`). `type` always matches `data.status`. New types may appear; acknowledge them with a `2xx`.

### Signatures

HarborPay signs webhooks the [Standard Webhooks](https://www.standardwebhooks.com/) way, so Standard Webhooks libraries work if you give them the raw body.

| Header | Value |
|---|---|
| `webhook-id` | The event id. It stays the same across retries and redeliveries. |
| `webhook-timestamp` | Unix seconds when this attempt was signed |
| `webhook-signature` | Space-separated `v1,<base64>` entries; accept the request if any entry matches |
| `user-agent` | `HarborPay-Webhooks/1` |

Your webhook secret looks like `whsec_` followed by base64. To verify:

1. Take the **raw** request body, exactly as received. Don't parse and re-serialize it first.
2. Build the signed content `{webhook-id}.{webhook-timestamp}.{raw body}`.
3. Compute HMAC-SHA256 over it. The key is the base64-decoded part of the secret after `whsec_`. Base64-encode the result.
4. Compare it with each `v1,` entry of `webhook-signature` in constant time.
5. Reject timestamps more than 5 minutes (300 seconds) away from your clock.

Only then parse the JSON.

```js
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyHarborPay(secret, headers, rawBody, nowS = Math.floor(Date.now() / 1000)) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  if (!id || !timestamp || Math.abs(nowS - Number(timestamp)) > 300) return false;
  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
  return (headers["webhook-signature"] ?? "").split(" ").some((entry) => {
    const [version, signature] = entry.split(",");
    const got = Buffer.from(signature ?? "", "base64");
    return version === "v1" && got.length === expected.length && timingSafeEqual(got, expected);
  });
}
```

### Test vector

Use this to check your verifier. The timestamp is in the past, so compute the signature yourself or fix your clock at `1790000000`; a live verifier rightly calls it stale.

- Secret: `whsec_aGFyYm9ycGF5LWRvY3MtZXhhbXBsZS1rZXktMDAwMDAx`
- `webhook-id`: `evt_01J8Z3K4Q2V7XH5N9M6T0P1R2S`
- `webhook-timestamp`: `1790000000`
- Body (exact bytes, no trailing newline):

```
{"id":"evt_01J8Z3K4Q2V7XH5N9M6T0P1R2S","type":"payout.settled","createdAt":"2026-09-21T14:13:20.000Z","data":{"id":"hp_po_01J8Z3K0000000000000000000","status":"SETTLED","statusVersion":3}}
```

- Expected `webhook-signature`: `v1,efUQhNDoZ1f7z5JyAKoWjb3jVxHyxqwQv6ch2w/y99o=`
- The same body with one trailing space must fail.

The Standard Webhooks specification's own example (`msg_p5jXN8AQM9LWM0D4loKWxJek`, timestamp `1614265330`) verifies with the same routine.

### Test webhooks

`POST /webhooks/test` sends a signed `ping` to your webhook endpoint. It is attempted once, with no retries, and you can send up to 10 per rolling minute; more get `429 RATE_LIMITED`. Until you set an endpoint it gets `422 WEBHOOK_URL_NOT_SET`.

## Sandbox notes

A payout usually moves to `PROCESSING` about 5 seconds after creation and settles about 5 seconds after that; timings vary. Payouts in the sandbox usually succeed within seconds. The other outcomes described here (reviews, failures, returns, delays) can happen, as they do in production; you can't request them.

**Test values.** BIC `TSTBUS33` or `TSTBUS33XXX`, account number `000123456789`. A non-US BIC such as `TSTBGB22` gets `400` at `bankAccount.bic`. Linked accounts are active at once; beneficiaries become active a few seconds after creation. No amount or reference triggers a particular outcome.
