# Payments

Harakumo is the merchant: the buyer pays on a hosted checkout, the money is recorded as your project's Earnings, and after the hold the workspace owner withdraws it to a payout account in their own name (a one-time identity check with our payout provider).

Earnings are not the workspace Balance. The Balance is money you load to pay Harakumo; Earnings are money your customers paid you. They are never mixed.

## The flow

1. Enable payments on the project — one call, live at once.
2. Create a checkout link for an amount, with your own reference and metadata and a successUrl on your site.
3. Send the buyer to the link. It can be paid once and expires after 24 hours.
4. After paying, the buyer returns to your successUrl with `session_id` added.
5. Your server confirms the payment with the session lookup, or receives the payment.succeeded webhook.
6. The net (amount less the fee) is added to Earnings, held 7 days, then withdrawable by the workspace owner.

## Fees and rules

| Item | Rule |
| --- | --- |
| Platform fee | 3.9% + 30¢ per sale — the processor's cost plus 1% for Harakumo |
| Hold | 7 days per payment before it can be withdrawn |
| Payout limits | a new seller's first payout comes 14 days after their first sale; payouts start at up to $500 each and $1,000 a week per workspace, and rise as the workspace keeps selling and payouts go through, to $50,000 and $100,000 after 180 days, 12 payouts and $50,000 paid out. Payouts pause while over 10% of the last 90 days' sales is refunded, or while 2 or more disputes make up 1% of them (3 disputes pause payouts whatever the share). Earnings kept back wait for the next payout; `earnings.payout_limit.message` says why and when it lifts |
| Amount | $0.50 to $1,000,000, USD, one-time payments |
| Link | single use, expires after 24 hours |
| Refund | your Earnings go down by the same share of your net as the refund is of the payment |
| Dispute | the payment's net is held back from Earnings while the dispute is open; a won dispute gives it back |
| Live links | need a verified workspace-owner email (403 `email_unverified` otherwise) |

## Create a checkout link

| Field | Rules |
| --- | --- |
| amountCents | required, at least 50 |
| description, customerEmail | optional |
| reference | your own id for the sale (order, user …), up to 200 characters; filter lists by it |
| metadata | up to 20 string keys; keys starting with hk_ are reserved |
| successUrl | http or https. `session_id={CHECKOUT_SESSION_ID}` is added unless you put the placeholder in yourself. Without it the buyer sees Harakumo's public thank-you page. |
| cancelUrl | where the buyer goes if they back out |

**curl**

```bash
curl -X POST https://harakumo.com/api/payments/<accountId> \
  -H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
  -d '{ "amountCents": 2900, "description": "Pro plan", "customerEmail": "buyer@example.org",
        "reference": "order-1042", "metadata": { "userId": "42" },
        "successUrl": "https://yourapp.com/thanks", "cancelUrl": "https://yourapp.com/cart" }'
# 201 → { "url": "…", "session": { "id", "state": "open", "expires_at", "single_use": true, "reference", "metadata", … }, "feeCents" }
```

**SDK**

```js
const { url, session } = await hk.payments.createSession(accountId, {
  amountCents: 2900, description: 'Pro plan', reference: 'order-1042', metadata: { userId: '42' },
  successUrl: 'https://yourapp.com/thanks', cancelUrl: 'https://yourapp.com/cart',
});
```

## Confirm a payment

On your success page, look the session up with the id from the redirect. `paid` is true once the processor has confirmed it (an open checkout is checked with the processor on read, so this never waits on a webhook).

**fetch**

```js
const r = await fetch('https://harakumo.com/api/payments/' + accountId + '/sessions/' + encodeURIComponent(sessionId), {
  headers: { authorization: 'Bearer ' + process.env.HARAKUMO_API_KEY },
});
const { paid, payment, session } = await r.json();
// payment: { amount_cents, fee_cents, net_cents, customer_email, reference, metadata, status, available_at, … }
```

## Webhooks

Register an https URL with `PATCH /api/payments/:accountId { action: "set-webhook", webhookUrl }`. The signing secret (`hkwhsec_…`) is returned once. Events: payment.succeeded, payment.refunded, payment.disputed, payment.dispute_won, and ping (from `action: "test-webhook"`).

Each delivery is a POST with the JSON event and three headers: Harakumo-Event (the type), Harakumo-Delivery (the event id — use it to ignore duplicates) and Harakumo-Signature (`t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">`). Answer 2xx within 5 seconds; redirects are not followed. Failed deliveries are retried up to 8 times, backing off 1 minute, 5 minutes, 30 minutes, 2, 6, 12 and 24 hours.

**Verify and handle (Express)**

```js
import crypto from 'node:crypto';
import express from 'express';

const app = express();

// Verify against the raw body, before any JSON parsing.
app.post('/webhooks/harakumo', express.raw({ type: 'application/json' }), (req, res) => {
  const parts = Object.fromEntries((req.get('harakumo-signature') || '').split(',').map(p => p.trim().split('=')));
  const t = Number(parts.t);
  const expected = crypto.createHmac('sha256', process.env.HARAKUMO_WEBHOOK_SECRET)
    .update(t + '.' + req.body.toString('utf8'))
    .digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - t) <= 300;
  const valid = fresh && typeof parts.v1 === 'string' && parts.v1.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  if (!valid) return res.status(400).send('bad signature');

  const event = JSON.parse(req.body.toString('utf8'));
  if (event.type === 'payment.succeeded') {
    const p = event.data.payment;   // mark order p.reference as paid — idempotently
  }
  res.sendStatus(200);
});
```

**Event body**

```json
{
  "id": "evt_ps_318", "type": "payment.succeeded", "created": 1790000000,
  "data": { "payment": {
    "id": 318, "account_id": 9, "project_id": 12, "session_id": 771, "session_key": "…",
    "reference": "order-1042", "metadata": { "userId": "42" },
    "amount_cents": 2900, "fee_cents": 143, "net_cents": 2757, "refunded_cents": 0,
    "currency": "usd", "customer_email": "buyer@example.org", "country": "US",
    "status": "succeeded", "created_at": "2026-09-25 14:02:11"
  } }
}
```

> Rotate the secret with `action: "rotate-webhook-secret"`. Every delivery and its result is listed on the project's Payments page.

## Lists

`GET /api/payments/:accountId/payments` and `/sessions` page through one project's payments and checkout links (`?limit=1..100&cursor=&status=&reference=`, answering `nextCursor`). `GET /api/payments` lists the workspace's recent payments across projects. `GET /api/projects/:id/payments` returns the account, recent activity and `earnings` (balance, held, available, withdrawn, pending, next release).

## Refunds, withdrawals and turning it off

- Refund all or part of a payment: `PATCH { action: "refund", paymentId, amountCents? }`.
- Withdraw: connect a payout account once (`withdraw-setup` returns an identity-check link), then `withdraw` sends what is available, up to the workspace's payout limits (409 `payout_limited`, with the reason and the date it lifts, when none can go yet). A withdrawal the processor has not confirmed stays pending until it does; it is never re-credited on a guess.
- Refunds, payout setup, the payout dashboard, withdrawals and permanent delete need the workspace owner signed in to the dashboard. API keys, developers and AI connectors get 403 `payouts_require_owner`.
- `DELETE /api/payments/:accountId` disables payments: no new links, but earnings, history and the payout account are kept and links already sent are still credited. `POST /api/projects/:id/payments` turns it back on. `?permanent=true` erases the account, only when nothing is owed or open.

## Limits and scale

- USD and one-time payments only: no subscriptions, products, coupons or tax yet. Each link is for one payment.
- Payment accounts per plan: 1 hobby, 10 pro, 100 enterprise (one per project).
- Payments are recorded in Harakumo's ledger, not polled by your app: use the webhook for volume, and the session lookup on the success page.
- Keys: a key limited to the project (full access) can enable payments, create links, look sessions up, list payments and set the webhook. A read-only key can look up and list. Money leaving — refunds and withdrawals — always needs the owner.

## Reference

**SDK**

```js
const { account } = await hk.payments.enable(project.id);
const { url } = await hk.payments.createSession(account.id, { amountCents: 2900, description: 'Pro plan', successUrl: 'https://yourapp.com/thanks' });
const { account: a, earnings } = await hk.payments.get(project.id);
const { totals, funnel, daily } = await hk.payments.analytics(project.id);
// Session lookup, lists, refunds and webhooks: REST (below).
```

**REST**

```http
POST   /api/projects/:id/payments                 # enable (or re-enable)
GET    /api/projects/:id/payments                 # account, earnings, recent payments and links
POST   /api/payments/:accountId                   { "amountCents", "description"?, "customerEmail"?, "reference"?, "metadata"?, "successUrl"?, "cancelUrl"? }
GET    /api/payments/:accountId/sessions/:sessionId   → { paid, payment, session }
GET    /api/payments/:accountId/payments?limit=&cursor=&status=&reference=
GET    /api/payments/:accountId/sessions?limit=&cursor=&status=&reference=
GET    /api/payments?limit=&cursor=               # the whole workspace
GET    /api/projects/:id/payments/analytics
PATCH  /api/payments/:accountId                   { "action": "set-webhook", "webhookUrl" } | { "action": "rotate-webhook-secret" } | { "action": "test-webhook" }
PATCH  /api/payments/:accountId                   { "action": "refund", "paymentId", "amountCents"? }      # owner, signed in
PATCH  /api/payments/:accountId                   { "action": "withdraw-setup" } | { "action": "withdraw" } # owner, signed in
DELETE /api/payments/:accountId[?permanent=true]
```
