Skip to content
Docs · App services

Payments

Take card payments without opening a processor account. Checkout links, a return to your site, session lookup, signed webhooks and refunds. The fee is 3.9% + 30¢ per sale; earnings are held 7 days, then the owner withdraws.

View as Markdown
All topics
On this page

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.

customercheckout session$29.00 · card⟶ paidyour payoutplatform feeper-project payments with payouts

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

ItemRule
Platform fee3.9% + 30¢ per sale — the processor's cost plus 1% for Harakumo
Hold7 days per payment before it can be withdrawn
Payout limitsa 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
Linksingle use, expires after 24 hours
Refundyour Earnings go down by the same share of your net as the refund is of the payment
Disputethe payment's net is held back from Earnings while the dispute is open; a won dispute gives it back
Live linksneed a verified workspace-owner email (403 email_unverified otherwise)
FieldRules
amountCentsrequired, at least 50
description, customerEmailoptional
referenceyour own id for the sale (order, user …), up to 200 characters; filter lists by it
metadataup to 20 string keys; keys starting with hk_ are reserved
successUrlhttp 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.
cancelUrlwhere the buyer goes if they back out
curl
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
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
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)
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
{
  "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
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).