Harakumo Docs
One API key, your whole cloud. Every service through the SDK, the CLI, plain HTTPS or an AI assistant.
Topics
Getting started
Everything you create lives in a project, projects live in a workspace, and the workspace holds the plan, the Balance and the team. Every service is reachable four ways: the dashboard, plain HTTPS (https://harakumo.com/api/…), the JavaScript SDK and the CLI. An AI assistant connected over MCP uses the same API, with your permissions.
New here? The Quickstart builds a working app with a database, browser uploads and payments, step by step.
The pieces
| Level | What it is |
|---|---|
| Account | You: an email address, a password and optional two-factor sign-in. |
| Workspace | Where billing and people live: one plan, one Balance, one team with roles. Signing up creates a personal workspace; you can create more and be invited to others. |
| Project | One app. It has its own address (https://<slug>.harakumo.app), environment variables and resources. |
| Resource | A database, bucket, function, auth pool, payment account, vector collection, memory store, agent or media asset inside a project. |
Install
npm install @harakumo/sdknpm install -g @harakumo/cli
harakumo login --key hk_live_… # saved to ~/.harakumo/config.json
harakumo docs # these docs, in the terminalMake your first call
- Create a key in Dashboard → Settings → API access (owners and admins can). It is shown once; store it as
HARAKUMO_API_KEY. - Send it as a Bearer token. The same key works for every service and every interface.
curl https://harakumo.com/api/projects \
-H "Authorization: Bearer $HARAKUMO_API_KEY"import Harakumo from '@harakumo/sdk';
const hk = new Harakumo({ apiKey: process.env.HARAKUMO_API_KEY });
const { projects, org } = await hk.projects.list();Keys are secrets: use them from servers, scripts and CI, never in a browser or a mobile app. For sign-in from a browser, auth pools have a publishable client id instead (see Auth pools).
Where to go next
- Quickstart — signup to a live app with a database, storage and payments.
- API keys & authentication — full, read-only and project keys, and which to give an app.
- Limits & scaling — every number, and how each service behaves under load.
- Errors & rate limits — what each status and code means.
- AI connector (MCP) — run all of it from Claude or ChatGPT.
Quickstart: your first app
You will build a small notes app: an Express server that stores notes in an edge SQLite database, lets the browser upload attachments straight to object storage, and sells an upgrade through a hosted checkout. It deploys as a server app at https://<slug>.harakumo.app.
Replace the values in angle brackets with the ones each step prints.
1. Sign up and create a setup key
- Sign up at harakumo.com/signup and enter the 6-digit code emailed to you. Verify now: live payment links need a verified workspace-owner email.
- Open Dashboard → Settings → API access and create a key with full access to the whole workspace. It stays on your machine for setup.
npm install -g @harakumo/cli
export HARAKUMO_API_KEY=hk_live_… # the key you just created
harakumo login --key $HARAKUMO_API_KEY2. Create the project, a database, a bucket and a payment account
harakumo projects create notes-app # ✓ Created project notes-app #<projectId>
harakumo db create <projectId> notes # ✓ Creating notes (sqlite, #<dbId>)
harakumo db query <dbId> "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT NOT NULL, attachment TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP)"
harakumo storage create <projectId> uploads
harakumo storage <projectId> # lists buckets with their ids: note <bucketId>
# Payments are live as soon as they are enabled:
curl -X POST https://harakumo.com/api/projects/<projectId>/payments \
-H "Authorization: Bearer $HARAKUMO_API_KEY"
# → { "account": { "id": <accountId>, … } }3. Give the app its own key
Your setup key can do anything in the workspace; the deployed app should not. In Settings → API access create a second key with full access and Project set to notes-app. It works on this project's database, buckets and payment account and is refused everywhere else.
Store it and the resource ids as the project's environment variables — in the dashboard (project → Environment) or with the API:
| Variable | Value |
|---|---|
| HARAKUMO_API_KEY | the project key (hk_live_…) |
| NOTES_DB_ID | <dbId> |
| UPLOADS_BUCKET_ID | <bucketId> |
| PAYMENTS_ACCOUNT_ID | <accountId> |
| APP_URL | https://<slug>.harakumo.app (the slug is on the project page) |
curl -X POST https://harakumo.com/api/projects/<projectId>/env \
-H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
-d '{"key":"NOTES_DB_ID","value":"<dbId>"}'Environment variables are copied into the app when it deploys. After changing one, redeploy.
4. Write the app
Three files in a folder called notes-app. Keep the page in web/: an index.html in the folder root, or in public/, dist/, build/, out/, _site/, site/ or docs/, makes the pipeline serve the folder as a static site instead of starting your server.
{
"name": "notes-app",
"type": "module",
"scripts": { "start": "node server.js" },
"dependencies": { "@harakumo/sdk": "latest", "express": "^4.21.2" }
}import express from 'express';
import Harakumo from '@harakumo/sdk';
const hk = new Harakumo({ apiKey: process.env.HARAKUMO_API_KEY });
const DB = Number(process.env.NOTES_DB_ID);
const BUCKET = Number(process.env.UPLOADS_BUCKET_ID);
const PAYMENTS = Number(process.env.PAYMENTS_ACCOUNT_ID);
const APP_URL = process.env.APP_URL;
const app = express();
app.use(express.json());
app.use(express.static('web'));
// Notes live in the edge SQLite database; every query is one HTTPS call.
app.get('/api/notes', async (req, res) => {
const { results } = await hk.databases.query(DB,
'SELECT id, body, attachment, created_at FROM notes ORDER BY id DESC LIMIT 50');
res.json(results);
});
app.post('/api/notes', async (req, res) => {
const { body, attachment = null } = req.body;
await hk.databases.query(DB, 'INSERT INTO notes (body, attachment) VALUES (?, ?)', [String(body), attachment]);
res.status(201).json({ ok: true });
});
// The browser uploads straight to storage with a signed URL; the bytes never touch this server.
app.post('/api/upload-url', async (req, res) => {
const { filename, contentType } = req.body;
const key = 'attachments/' + Date.now() + '-' + String(filename).replace(/[^a-zA-Z0-9._-]/g, '_');
const { url } = await hk.storage.presign(BUCKET, { key, method: 'put', contentType });
res.json({ url, key });
});
// A hosted checkout. The buyer comes back to /thanks?session_id=…
app.post('/api/checkout', async (req, res) => {
const { url } = await hk.payments.createSession(PAYMENTS, {
amountCents: 500,
description: 'Notes Pro',
reference: 'user-' + String(req.body.userId || 'anonymous'),
successUrl: APP_URL + '/thanks',
cancelUrl: APP_URL + '/',
});
res.json({ url });
});
// Confirm on the server; never trust the redirect alone.
app.get('/thanks', async (req, res) => {
const r = await fetch('https://harakumo.com/api/payments/' + PAYMENTS + '/sessions/'
+ encodeURIComponent(req.query.session_id || ''), {
headers: { authorization: 'Bearer ' + process.env.HARAKUMO_API_KEY },
});
const { paid, payment } = await r.json();
res.send(paid ? 'Paid $' + (payment.amount_cents / 100).toFixed(2) + ' — thank you!' : 'Payment not completed yet.');
});
app.listen(process.env.PORT || 3000);<!doctype html>
<title>Notes</title>
<form id="note"><input name="body" placeholder="Write a note" required> <input type="file" name="file"> <button>Save</button></form>
<ul id="list"></ul>
<button id="buy">Upgrade for $5</button>
<script type="module">
const json = (url, body) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).then(r => r.json());
async function load() {
const notes = await (await fetch('/api/notes')).json();
document.getElementById('list').replaceChildren(...notes.map(n => {
const li = document.createElement('li');
li.textContent = n.body + (n.attachment ? ' (attachment)' : '');
return li;
}));
}
document.getElementById('note').onsubmit = async (e) => {
e.preventDefault();
const form = new FormData(e.target);
const file = form.get('file');
let attachment = null;
if (file && file.size) {
const type = file.type || 'application/octet-stream';
const { url, key } = await json('/api/upload-url', { filename: file.name, contentType: type });
await fetch(url, { method: 'PUT', headers: { 'content-type': type }, body: file }); // same Content-Type as signed
attachment = key;
}
await json('/api/notes', { body: form.get('body'), attachment });
e.target.reset();
load();
};
document.getElementById('buy').onclick = async () => {
location.href = (await json('/api/checkout', {})).url;
};
load();
</script>5. Deploy
harakumo deploy <projectId> ./notes-app # uploads the folder
harakumo deploys <projectId> # queued → building → deploying → readyDeploy the folder without node_modules: an upload is at most 25 MB, and the build machine installs dependencies itself. Keep secrets in environment variables, not in a .env file.
What happens
The pipeline finds a package.json with a start script and no static index.html, installs the dependencies on a build machine and runs npm start in a container. The first deploy takes a few minutes. When the status is ready, open https://<slug>.harakumo.app.
- A server app listens on
process.env.PORT; the platform sets it. - An idle app sleeps after 15 minutes. The next visitor sees a short starting page while it wakes.
- The container's disk is wiped on sleep and on every deploy. Keep data in the database and storage, as this app does.
6. Take a payment
Press Upgrade on the live app. The hosted checkout charges a real card: to try the flow, pay yourself and refund it from Dashboard → Payments (refunds are for the workspace owner). You return to /thanks?session_id=…, and the server confirms the payment with the session lookup.
The payment appears in Dashboard → Payments as Earnings: $5.00 less the 3.9% + 30¢ fee. Each payment is held 7 days before the workspace owner can withdraw it.
Next
- Databases — batch statements, export, limits and errors.
- Storage — download links, listings and folders.
- Payments — webhooks, metadata and refunds.
- Auth pools — add sign-in for your users.
- Limits & scaling — what this app can handle, and where the walls are.
API keys & authentication
Send the credential in the Authorization header: Authorization: Bearer hk_live_…. The same header works for REST, the SDK, the CLI and the MCP connector.
Owners and admins create keys in Dashboard → Settings → API access. A key is shown once and stored only as a hash; it does not expire, and revoking it takes effect immediately.
Kinds of key
| Key | Acts as | Can |
|---|---|---|
| Full, whole workspace | developer | Create, change and delete resources in every project, deploy, send mail, use AI, buy domains from the Balance. It can also delete projects. |
| Full, one project | developer | The same, but only on that project's endpoints. Workspace-wide endpoints refuse it. |
| Read-only, whole workspace | viewer | GET requests, plus reads sent as POST (vector query, memory search, storage download links). No environment variables, no SQL, no uploads. |
| Read-only, one project | viewer | Reads on that project only. |
No API key can manage billing, members or other keys, or move money out: withdrawals, refunds and payout setup need the workspace owner signed in to the dashboard.
Which key to give what
| Use | Key |
|---|---|
| Your laptop, CI, setup scripts | Full, whole workspace |
| A deployed app that uses its database, storage, payments, vectors, memory or auth pool | Full, one project |
| A deployed app that also sends mail or calls the AI Gateway | Full, whole workspace (those endpoints are workspace-wide and refuse project keys today) |
| Dashboards, monitoring, read-only AI assistants | Read-only |
Where a project key works
A project key passes every route that names its project: /api/projects/<id>/…, and item routes whose resource belongs to it — /api/databases/:id, /api/storage/:id, /api/functions/:id, /api/vectors/:id, /api/memory/:id, /api/agents/:id, /api/payments/:accountId, /api/auth-pools/:id, /api/deployments/:id.
It is refused, with 403 "This API key only works on project N", on workspace-wide routes: listing projects, /api/mail/send, /api/ai/*, /api/domains/*, /api/repos/*, /api/usage and billing.
Create a key with the API
curl -X POST https://harakumo.com/api/orgs/<workspaceId>/api-keys \
-H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
-d '{ "name": "notes-app production", "access": "full", "projectId": 12 }'
# 201 → { "key": { "id", "prefix", "access", "project_id" }, "secret": "hk_live_…" } (secret shown once)Creating keys needs the admin role, so an API key (developer or viewer) cannot mint more keys. Use the dashboard, or an admin's connector.
Other credentials
| Credential | Where it comes from | Acts as |
|---|---|---|
| Browser session | Signing in to the dashboard; an httpOnly cookie, 30 days | You, with your role in the chosen workspace |
| Connector token (hk_mcp_…) | Connecting Claude, ChatGPT or another MCP client (OAuth) | You, read-only or read-and-manage, in one workspace or all of them |
| Auth-pool client secret (as_…) | Enabling an auth pool | Only that pool's sign-in actions — never anything else |
| Auth-pool client id (ap_…) | Enabling an auth pool | Publishable: browser sign-in for that pool, when turned on |
Choosing a workspace
A key belongs to one workspace. A session or an all-workspaces connector acts in the workspace named by ?org=<id> or the x-harakumo-org header, else the one last chosen in the dashboard.
When a key is refused
| Status | Message | What to do |
|---|---|---|
| 401 | Unauthorized | The header is missing, or the key was revoked or mistyped. |
| 403 | This API key is read-only | Use a full key for writes. |
| 403 | This API key only works on project N | Use a key for that project, or a workspace key for workspace-wide routes. |
| 403 | Your role (developer) does not allow this action | The action needs an admin or owner (members, keys, billing). |
| 404 | Not found | The id does not exist or belongs to another workspace — the two look the same on purpose. |
Reference
import Harakumo from '@harakumo/sdk';
const hk = new Harakumo({ apiKey: process.env.HARAKUMO_API_KEY }); // baseUrl defaults to https://harakumo.com
// Errors throw HarakumoError with .status and .body (the JSON the API sent).Limits & scaling
Plans are per workspace and limit how many things you create and the monthly allowances. The technical limits below apply on every plan. Data size, storage bytes, bandwidth and function traffic have no plan quota yet — they are listed as not metered.
Plans
| Hobby | Pro | Enterprise | |
|---|---|---|---|
| Price (per workspace) | Free | $20 a month | $500 a month |
| AI usage included | $1 a month (personal workspace only) | $10 a month | $100 a month |
| Domains included | None | 1 a year (up to $15 each) | 5 a year (up to $15 each) |
| Emails (rolling 30 days) | 100 | 5,000 | 100,000 |
| Custom sending domains | 1 | 10 | 50 |
| Projects | 3 | 20 | 1,000 |
| Functions | 5 | 50 | 1,000 |
| Databases (10 GB each) | 1 | 10 | 100 |
| Storage buckets | 2 | 20 | 500 |
| Vector collections | 1 | 10 | 200 |
| Memory stores | 2 | 20 | 500 |
| Agents | 2 | 20 | 500 |
| Auth pools | 1 | 10 | 100 |
| Payment accounts | 1 | 10 | 100 |
| Media assets | 5 | 100 | 1,000 |
| Repos | 5 | 50 | 500 |
| Custom domains connected | 2 | 20 | 500 |
| Team members | 3 | 10 | 100 |
| Workspaces you can own | 2 | 10 | 100 |
| AI requests a minute | 60 | 600 | 6,000 |
| Agent runs at once | 1 | 5 | 20 |
Past a count the create call answers 402 with code plan_limit, the limit and the current count. Nothing existing is deleted when a plan changes; only new creates are refused.
Not metered or capped yet
- Requests and bandwidth to sites and server apps, and traffic to function URLs.
- Storage bytes (measured daily and shown in Analytics, but no quota).
- Database size (measured daily; each database stops at the engine's 10 GB).
- Server-app running time, errors and latency.
Databases
| Limit | Value |
|---|---|
| Size of one database | 10 GB |
| One row or value | 2 MB |
| One SQL statement | 100 KB |
| Bound parameters per statement | 100 |
| Time per query | 30 seconds |
| Columns per table | 100 |
| Statements in one batch | 100 |
| Query requests per workspace | 600 per 5 minutes (a batch counts once) |
Deploys and server apps
| Limit | Value |
|---|---|
| One upload (folder, tar.gz or JSON files) | 25 MB compressed, 40 MB unpacked |
| One file | 10 MB |
| Files in one upload | 5,000 |
| Files a static site serves | 500 |
| Git repository download | 20 MB compressed, no file-count cap |
| Build time | about 13 minutes |
| Server app | one container per app (about a quarter of a CPU and 1 GB of memory), no extra copies |
| Server app sleep | after 15 idle minutes; disk wiped on sleep and on every deploy |
| Awake server apps | 40 across the whole platform today; sleeping apps do not count |
Functions, storage and repos
| Limit | Value |
|---|---|
| Function code | one ES module, at most 200 KB, no npm install |
| Test invoke | first 16 KB of the response, 10-second timeout |
| One storage upload | 5 GB (one signed PUT) |
| Object key | 1,024 characters |
| Signed link lifetime | 60 seconds to 7 days (default 15 minutes) |
| Objects per listing page | 1,000 (default 100), with a cursor for the next page |
| Repo push over REST | 500 files, 10 MB per file, 25 MB per push |
AI, vectors, memory and agents
| Limit | Value |
|---|---|
| Gateway input | 200,000 characters (system prompt plus messages), up to 500 messages |
| Gateway output | max_tokens up to 8,192 (2,048 on edge models); default 1,024 |
| AI requests a minute | 60 hobby, 600 pro, 6,000 enterprise — chat and embeddings counted separately |
| Embeddings per call | 100 inputs |
| Vector dimensions | 384, 768 (default), 1,024 or 1,536 |
| Vectors per upsert | 1,000 (100 when sending text to embed) |
| Vector query topK | 100, or 50 with full metadata or values |
| Memory value / key | 1 MB / 512 characters |
| Conversation window | 1,000 most recent messages per scope |
| Memory quotas | items per store and bytes per workspace, by plan; a write past them gets 402 |
| Agent steps | 1–24 per run (default 8) |
| Agent runs | 6 a minute per workspace; input up to 32,000 characters |
| Media upload | video only, up to 1 hour |
Mail, payments and auth pools
| Limit | Value |
|---|---|
| Email per call | one recipient; text and/or HTML |
| Email quota | 100 hobby, 5,000 pro, 100,000 enterprise per rolling 30 days; optional daily cap of your own |
| Custom sending domains | 1 hobby, 10 pro, 50 enterprise per workspace (pending and failed domains count until removed) |
| Checkout amount | $0.50 to $1,000,000, USD, one-time |
| Checkout link | single use, expires after 24 hours |
| Earnings hold | 7 days per payment before it can be withdrawn |
| End-user password | 8–256 characters; 10 wrong tries lock the account for 15 minutes |
| End-user token | HS256 JWT, valid 7 days |
| Browser sign-up | 10 per IP an hour, 600 per pool an hour |
Rate limits
Every 429 carries code, a Retry-After header and retry_after seconds in the body. API quotas also send X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. Back off for Retry-After seconds and try again.
| What | Limit | Code |
|---|---|---|
| AI chat | 60 / 600 / 6,000 a minute (hobby / pro / enterprise) | ai_rate_limited |
| Embeddings, memory search, text sent to vectors | a separate bucket of the same size | ai_rate_limited |
| Database queries, vector calls, semantic memory | 600 calls per 5 minutes per workspace, shared (a batch of statements is one call) | rate_limited |
| Agent runs | 6 a minute per workspace | rate_limited |
| Balance top-ups | 10 an hour | rate_limited |
| New workspaces | 5 a day per person | rate_limited |
| your optional daily cap | daily_cap |
How it scales
| Service | At millions of users today |
|---|---|
| Static sites | Scale: served from the edge cache close to each visitor, the rest from object storage. |
| Functions | Scale: each request runs at the edge location nearest the caller, with no servers to size. |
| Storage | Bytes scale: uploads and downloads go straight to object storage. Each signed link is one API call to Harakumo, so sign links with a longer expiresIn and reuse them rather than signing one per page view. |
| Auth pools | Verify tokens in your app with the JWT secret (no call per request). Sign-ups and logins run on the shared platform and suit apps with thousands of active users. |
| Databases | Each database has one writer and stops at 10 GB, and each query is an HTTPS call through Harakumo capped at 600 requests per 5 minutes per workspace. Good for development, internal tools and low-traffic apps; not yet for a high-traffic public app. Batch statements, cache reads and split data across databases. |
| Server apps | One small container per app with no extra copies, a cold start after it sleeps, and a platform-wide cap on awake apps. For large traffic, put the front end on a static site and the server logic in functions. |
| AI | Model capacity scales with the vendors; your workspace is capped by the per-minute limits above. |
| Up to the plan's 30-day quota, one recipient per call. |
Coming: databases reached directly from your functions and apps (no HTTPS hop, no shared limit), read replicas, a Postgres engine for datasets over 10 GB, extra copies and larger sizes for server apps, and metered traffic.
Errors & rate limits
Errors come back as { "error": "…", "code"?: "…", …details }. The sentence says what happened and what to do; the code is stable for programs. Nothing is charged for a refused request.
Status codes
| Status | Meaning |
|---|---|
| 400 | The request is wrong: a missing field, a bad value, or a SQL mistake (the database's own message, cleaned). |
| 401 | No credential, or it is invalid or revoked. |
| 402 | Money or plan: a plan count reached, the Balance or AI credit too low, or the spending limit reached. |
| 403 | The credential may not do this: role, read-only key, project key, owner-only money action, a paused or unverified account. |
| 404 | Not found — or it belongs to another workspace. |
| 409 | The resource is in the wrong state: still being created, disabled, or a delete blocked by money or work in flight. |
| 410 | A database that no longer exists on the edge. |
| 413 | Too large: an upload over the size cap, or AI input over 200,000 characters. |
| 422 | Refused on purpose: a suppressed email address, or an AI answer that used the whole token budget with nothing to show. |
| 429 | Too many requests. Wait Retry-After seconds. |
| 501 | Not available yet (for example, a Postgres database or an image media asset). |
| 502 | Something upstream failed: a delivery provider refused, or a delete could not finish (it is safe to retry). |
| 503 | Temporarily unavailable. Retry after the Retry-After header when there is one. |
Codes
| Code | Status | Meaning and fields |
|---|---|---|
| plan_limit | 402 | A plan count or the email quota is reached. limit, current. |
| insufficient_balance | 402 | The Balance does not cover a purchase. price_usd, balance_usd, shortfall_usd, add_funds_url, card_on_file. |
| ai_credits_exhausted | 402 | Included AI and the Balance are used up. available_usd, resets_on, buy_url. |
| spend_limit_reached | 402 | The workspace's monthly spending limit would be passed. spend_limit_usd, spent_this_month_usd, limits_url. |
| email_unverified | 402 / 403 | The workspace owner's email must be verified (Hobby vendor AI models, live payment links). |
| payouts_require_owner | 403 | Withdrawals, refunds and payout setup need the owner signed in to the dashboard. |
| origin_not_allowed | 403 | An auth pool refused a browser Origin that is not on its list. |
| browser_access_off | 403 | An auth pool's browser sign-in is turned off. |
| mail_paused / mail_suspended | 403 | Sending is paused by an admin, or suspended by the platform. |
| invalid_domain | 400 | A sending domain that is not a real domain you could own (a single word, a test domain, a Harakumo name). |
| invalid_from / invalid_reply_to | 400 | A From or Reply-To address that is not allowed; the sentence says which rule. |
| from_domain_not_added | 400 | A from address on a domain that is not a sending domain of this workspace. Add it first. |
| domain_claimed_elsewhere / domain_taken | 409 | The domain belongs to another workspace on Harakumo. |
| payments_disabled | 409 | The payment account is disabled; re-enable it on the project. |
| deletion_blocked | 409 | A delete is refused. blockers[] has one sentence per reason (earnings, a withdrawal, an open checkout, a running deploy, bought domains). |
| confirm_forfeit_required | 409 | Deleting a workspace would give up a positive Balance; send confirmForfeit: true to accept. |
| suppressed | 422 | The recipient is on the suppression list. |
| rate_limited | 429 | A rate limit. retry_after; API quotas add limit and window_seconds. |
| ai_rate_limited | 429 | The AI per-minute limit. limit, window_seconds, retry_after_seconds. |
| daily_cap | 429 | The workspace's own daily email cap. |
| delivery_failed | 502 | The mail provider refused the message; message has the reason. |
| provider_error | 502 | A sending domain could not be set up; nothing was saved. Try again in a minute. |
| teardown_incomplete | 502 | A delete left some resources behind; the project is kept as teardown_failed. Delete again to retry. |
| processor_error | varies | The payment processor refused; the sentence says why. |
Handling errors in code
import Harakumo, { HarakumoError } from '@harakumo/sdk';
try {
await hk.databases.query(dbId, 'SELECT 1');
} catch (err) {
if (err instanceof HarakumoError) {
if (err.status === 429) { /* wait err.body.retry_after seconds, then retry */ }
if (err.body?.code === 'plan_limit') { /* show err.message to the person */ }
}
throw err;
}Architecture
Harakumo is an app's whole backend behind one client. Your front end and server deploy to the Harakumo edge; every capability the app needs is a namespace on the same SDK or a path on the same API, already wired together. The diagram shows a real shape — an online store, end to end — with the role each service plays.
Your server holds the API key; browsers get only what your server hands them (signed upload and download URLs, checkout links) or the auth pool's publishable client id.
Projects & environment
Each project gets a permanent slug, which becomes its address: https://<slug>.harakumo.app. Creating a project reserves the name; nothing serves until the first deploy succeeds. A project that only holds a database or storage has no website, and that is fine.
What a project reports
GET /api/projects/:id returns project (with live_url, null until a deploy is live), serving (state: live, down, deploying, failed or not_deployed, plus the serving deployment), deployAction (what Deploy does: rebuild the latest GitHub commit, or redeploy kept files) and services (counts of vectors, memory stores, agents, auth pools and media, and the payment account). The project id is on the project's Overview (Connect your app) and Settings.
Environment variables
- Set them in the dashboard (project → Environment) or with
POST /api/projects/:id/env { key, value }. A key is letters, digits and underscores; setting an existing key replaces it. - They are copied into sites, server apps and functions when those deploy. Change one, then redeploy (or press Redeploy) for it to take effect.
- Only the production target is used.
- Values are readable by developers and above, never by viewers or read-only keys. They are not encrypted secrets — do not reuse a value you could not afford a teammate to see.
Deleting a project
Deleting removes the site, functions, buckets and their files, databases, vector collections, memory stores, media and custom-domain connections. Domains bought in the workspace stay in the workspace and keep renewing.
- Refused with 409
deletion_blockedwhile the project holds unwithdrawn earnings, a pending withdrawal, a checkout link that can still be paid, or a running deploy.blockers[]says what to do. - If something cannot be removed, the answer is 502
teardown_incomplete; the project is kept with status teardown_failed, accepts only reads and deletes, and deleting again retries. - Deleting needs an admin, or a full workspace key. Project keys and read-only keys cannot delete projects.
Reference
const { project } = await hk.projects.create({ name: 'my-app' });
const { project: p, serving, services } = await hk.projects.get(project.id); // p.live_url, serving.state
const { projects } = await hk.projects.list();
await hk.projects.delete(project.id);Deployments
Three ways in, one pipeline: a GitHub repository (when you press Deploy or call the API), an upload (dashboard, CLI, SDK or files sent by an AI assistant), or a commit in Harakumo Repos. A failed deploy never replaces a working one.
How your code is recognized, in order
- A
worker.js(or_worker.js) at the root becomes an edge program with your production environment variables. - A Next.js app that is not a static export becomes a server app.
- An index.html in the root or in dist/, build/, out/, _site/, public/, site/ or docs/ becomes a static site. (From Git, a repository with a build script only serves dist/, build/, out/ or _site/.)
- A package.json build script (Vite, Astro, Create React App …) builds on a build machine; the output becomes a static site.
- A package.json start script (Express and friends) becomes a server app.
A server app must not carry a static index.html in the places step 3 looks, or it is served as a static site. Keep its pages in another folder.
Static sites
- Served from the edge cache (the workspace's CDN setting), then object storage. They scale with traffic.
- Up to 500 files per site.
- Dotfiles (.env, .git …) and key files (.pem, .key, id_rsa …) are never served;
/.well-known/is.
Server apps
- Run with
npm start(ornext start) on Node 22 in one container: about a quarter of a CPU and 1 GB of memory, one copy. - Listen on
process.env.PORT. Production environment variables are injected at deploy. - After 15 idle minutes the app sleeps; the next visitor sees a short starting page while it wakes (usually seconds, longer on a cold start).
- The disk is wiped on every sleep and deploy: keep uploads and data in Storage and Databases.
- If the app crashes three times, the project shows App crashed with the app's own output, and visitors get a plain 503 page.
- At most 40 server apps are awake across the platform today; sleeping apps do not count.
From GitHub
- Set the repository once (at project creation or
PATCH /api/projects/:id { repoUrl, repoBranch }). Only github.com repositories are supported. - Deploys run when you press Deploy or call the API. A push to GitHub does not deploy on its own yet.
- Only the production branch deploys. A different branch is refused unless
repoUrlis set in the same call, which makes that branch production. - Private repository: add a
GITHUB_TOKENenvironment variable to the project (a fine-grained token with read access to that repository). It is used only for that project.
From an upload
- Up to 25 MB compressed, 40 MB unpacked, 10 MB per file and 5,000 files.
- Leave out node_modules and build output: the build machine installs and builds. The dashboard leaves node_modules, .next, .git, .vercel, .turbo, .cache and .env files behind for you.
- Put secrets in environment variables, never in an uploaded .env file.
- A key limited to the project can deploy it; a read-only key can read deployments and logs.
Redeploy and roll back
POST /api/deployments/:id/redeploy deploys the same source again as a new deployment: the kept files of an upload (the serving deployment's files are kept) or a GitHub deployment's commit. Redeploying an older GitHub deployment is a rollback. This is also how environment variable changes take effect.
Status and logs
A deployment moves queued → building → deploying → ready, or failed with the reason in its logs. Server apps also report runtime status (running, crashed or failed) and recent output in runtime_status and runtime_logs.
const { deployment } = await hk.deployments.uploadDir(projectId, './dist');
let d = deployment;
while (!['ready', 'failed'].includes(d.status)) {
await new Promise(r => setTimeout(r, 3000));
({ deployment: d } = await hk.deployments.get(deployment.id));
}
console.log(d.status, d.preview_url);Reference
await hk.projects.update(project.id, { repoUrl: 'https://github.com/you/repo', repoBranch: 'main' });
const { deployment } = await hk.deployments.create(project.id); // latest commit on the production branch
const { deployment: d } = await hk.deployments.get(deployment.id); // status, logs, preview_url
const { deployment: up } = await hk.deployments.uploadDir(project.id, './my-app'); // Node: a folder
// await hk.deployments.uploadArchive(project.id, tarball); // a gzip tar (Buffer, Uint8Array or Blob)Functions
A function is one ES module with a default fetch handler, deployed to the edge. Each request runs at the location nearest the caller with no server to size or keep awake. Create one with code, or without code to get a starter handler you replace later.
The address
Every function is served at https://hk-<project-slug>--<function-name>.harakumo.app — never under the project's own site address. The create response carries it as function.url. A function created before the move to harakumo.app moves on its next redeploy, and its old address stops.
Runtimes
| runtime | What the code gets |
|---|---|
| node22 (default), node20 | Web APIs plus the Node.js compatibility layer: node:crypto, Buffer, process.env. |
| edge | Web APIs only: fetch, Request and Response, crypto.subtle, streams. |
Environment variables
- The project's production variables are available as
env.NAMEin the handler. - They are read when the function deploys. After changing one, redeploy the function with its code (
PUT /api/functions/:id). - They are visible to anyone who can read the project's environment; they are configuration, not a vault.
Limits
- One module, at most 200 KB, no npm install and no bundler — bundle dependencies yourself if you need them.
- Code that does not parse, or imports something that does not exist, is refused with 400 and the edge's reason; nothing is created.
- Invoking from the API or dashboard is a test call: it forwards method, path, headers and body, waits up to 10 seconds and returns the status, headers and the first 16 KB of the body. Real traffic goes to the URL and is not counted or capped yet.
- Developers and above can read a function's stored source; viewers cannot.
- Keys: a key limited to the project can create, deploy and delete its functions; a read-only key can read and test-invoke them.
Reference
const code = `export default {
async fetch(request, env) {
return Response.json({ hello: 'world', region: env.REGION ?? 'edge' });
},
};`;
const { function: fn } = await hk.functions.create(project.id, { name: 'hello', runtime: 'node22', code });
console.log(fn.url); // https://hk-<project>--hello.harakumo.app
await hk.functions.update(fn.id, { code }); // redeploy (also picks up env changes)Repos
Repos need no outside account or token. Every push saves a full snapshot as commit 1, 2, 3 …; deploying an older number is a rollback. An assistant connected over MCP can create a repo, push what it wrote and put a commit live in one conversation (create_repo, push_files, get_repo_files, deploy_repo, list_repos).
Limits
- A push over REST: up to 500 files, 10 MB per file, 25 MB in total. One push_files tool call is smaller (300 files of about 600 KB), so large projects push over REST or in parts.
- Text files up to 512 KB read back one by one; larger and binary files are listed with their size. Binary files are pushed base64-encoded (
"base64": true). - Environment files (.env and friends) are never readable back through the API. Do not commit secrets.
- Repos are workspace-wide: project keys cannot use them.
Reference
// Repos are reached over REST or the AI connector; the SDK has no repo methods yet.Databases
Every database you create is a separate SQLite database on the edge — two databases never share a file or a table. Engine "sqlite" is the one available today; Postgres, MySQL and Redis answer 501 until they exist.
There is no host and port: every query is a POST https://harakumo.com/api/databases/:id with an API key. Keep the key on your server.
Connect from your app
- Create the database (dashboard, CLI or
POST /api/projects/:id/databases { name }) and note its id. The database page's Connect tab shows the endpoint, the id and snippets with your real values. - Create an API key limited to the project (Settings → API access; full access, Project set). Queries need full access, even for SELECT.
- Put the key and the database id in the project's environment variables (for example HARAKUMO_API_KEY and NOTES_DB_ID) and redeploy.
- Query from server code: the SDK, fetch or curl.
const res = await fetch('https://harakumo.com/api/databases/' + process.env.NOTES_DB_ID, {
method: 'POST',
headers: { authorization: 'Bearer ' + process.env.HARAKUMO_API_KEY, 'content-type': 'application/json' },
body: JSON.stringify({ sql: 'SELECT * FROM notes WHERE id = ?', params: [42] }),
});
const { results, meta } = await res.json(); // results: rows of the last statementcurl -X POST https://harakumo.com/api/databases/5 \
-H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
-d '{ "sql": "SELECT count(*) AS n FROM notes" }'Responses
- A single request answers
{ results, statements, meta }. SQL holding several statements runs them in order;resultsholds the last statement's rows andstatementshow many ran. metahas the rows read and written, changes and last_row_id from the database.- Use
?placeholders withparamsfor every value — never build SQL by joining strings.
Batch
Send up to 100 statements, each with its own params, in one request. It counts once toward the workspace rate limit, so batching is the way to do bulk work. Do not rely on a failing statement rolling back the earlier ones in the same batch until that behavior is confirmed; design writes to be safe to retry.
curl -X POST https://harakumo.com/api/databases/5 \
-H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
-d '{ "batch": [
{ "sql": "INSERT INTO notes (body) VALUES (?)", "params": ["first"] },
{ "sql": "INSERT INTO notes (body) VALUES (?)", "params": ["second"] },
{ "sql": "SELECT count(*) AS n FROM notes" } ] }'
# → { "batch": [ { "results": [], "meta": {…} }, …, { "results": [{ "n": 2 }], "meta": {…} } ] }Export
POST /api/databases/:id/export starts an export and answers { status: "active", bookmark } while it runs; call again with the bookmark until it answers { status: "complete", download }. GET /api/databases/:id/export?bookmark=… returns the .sql file (schema and data), which any SQLite tool can load. The Connect tab has an Export button.
There is no point-in-time restore or import through Harakumo yet. Export regularly if the data matters, and treat delete as permanent.
Limits
| Limit | Value |
|---|---|
| Size of one database | 10 GB |
| One row or value | 2 MB |
| One SQL statement | 100 KB |
| Bound parameters per statement | 100 |
| Time per query | 30 seconds |
| Columns per table | 100 |
| Statements per batch | 100 |
| Query requests per workspace | 600 per 5 minutes |
| Databases per plan | 1 hobby, 10 pro, 100 enterprise |
Errors
| Status | Meaning |
|---|---|
| 400 | A SQL mistake; the message is the database's own. |
| 409 | The database is not available (its create failed, or it was never created). A failed create shows the reason; retry with PATCH { "action": "retry" } — it does not count against the plan. |
| 410 | The database no longer exists on the edge. |
| 429 | The workspace rate limit or the database service is throttling. Wait Retry-After seconds, and batch. |
| 502 / 503 | A platform problem. Retry shortly. |
How it scales
Each database has one writer and stops at 10 GB, and every query is an HTTPS call through Harakumo, capped at 600 requests per 5 minutes per workspace. That suits development, internal tools and low-traffic apps. For more: batch statements, cache reads in your app, and split data across several databases (per customer, per region). Direct connections from your functions and apps, read replicas and a Postgres engine for single datasets over 10 GB are planned.
Moving from MongoDB
@harakumo/docdb keeps the MongoDB and Mongoose call shapes (find, updateOne, populate, bulkWrite …) and stores documents as JSON in a Harakumo database, so an app moves without rewriting every query. It is in preview and not yet published to npm; ask support for access.
Reference
const { database } = await hk.databases.create(project.id, { name: 'app', engine: 'sqlite' });
const { results } = await hk.databases.query(database.id, 'SELECT * FROM users WHERE id = ?', [42]);
// Batches and export are REST calls (below); the SDK sends single statements.Storage
Files live in buckets; each bucket belongs to a project and is its own bucket in object storage. A file's key is its path (avatars/42.png); folders are just the part of the key before a slash.
Files are private. To upload or download, your server asks Harakumo for a signed URL for one key, and the client sends or fetches the bytes directly. There are no public bucket URLs and no S3-style access keys yet.
Upload from the browser
- Your server signs a PUT for a key, naming the Content-Type.
- The browser PUTs the file to that URL with the same Content-Type header. Buckets accept browser uploads from any origin.
const { url, expiresAt } = await hk.storage.presign(bucketId, {
key: 'avatars/' + userId + '.png', method: 'put', contentType: 'image/png',
});
// hand url to the browserawait fetch(url, { method: 'PUT', headers: { 'content-type': 'image/png' }, body: file });Download links
Sign a GET for a key and send the person (or an img tag) to the URL. Links last 15 minutes by default; pass expiresIn from 60 seconds to 7 days (604800). For files shown to many people, sign a longer link once and reuse it instead of signing on every view.
curl -X POST https://harakumo.com/api/storage/7 \
-H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
-d '{ "key": "reports/q3.pdf", "method": "get", "expiresIn": 86400 }'
# → { "url": "…", "expiresIn": 86400, "expiresAt": "…" }Listing and deleting
GET /api/storage/:idlists objects:prefixnarrows to a path,delimiter=/groups keys intofolders, andlimit(up to 1,000, default 100) withcursorpages through;nextCursoris set while more remain.POST { key, method: "delete" }removes one object.DELETE /api/storage/:idempties and deletes the bucket.- Sizes and file counts are measured daily (or on
?measure=1) and shown as a dash until a full count exists.
Who can do what
- Viewers and read-only keys can list and get download links.
- Uploading and deleting need the developer role (a full key).
- A key limited to the project works for all of it.
Limits and scaling
- One upload is at most 5 GB (one signed PUT; multipart is not offered yet).
- Keys are up to 1,024 characters.
- Buckets per plan: 2 hobby, 20 pro, 500 enterprise. There is no storage-bytes quota yet.
- The bytes scale without limit and never pass through Harakumo; each signed URL is one API call.
Reference
const { bucket } = await hk.storage.create(project.id, { name: 'uploads' });
const { url } = await hk.storage.presign(bucket.id, { key: 'file.png', method: 'put', contentType: 'image/png' });
const { url: download } = await hk.storage.presign(bucket.id, { key: 'file.png', method: 'get', expiresIn: 3600 });
const { objects, folders, nextCursor } = await hk.storage.objects(bucket.id); // first page
await hk.storage.deleteObject(bucket.id, 'file.png');Vector DB
Each collection is its own vector index. The default is 768 dimensions — what Harakumo's default embedding model (bge-base) produces, so text you send fits as-is. Choose 384 (bge-small) or 1,024 (bge-large) to match those models, or 1,536 for vectors you make elsewhere; larger is refused.
Embed, upsert, query
const { collection } = await hk.vectors.create(project.id, {
name: 'docs', dimensions: 768, metric: 'cosine',
metadataIndexes: [{ propertyName: 'lang', indexType: 'string' }],
});
// Send text and let Harakumo embed it (metered on AI credits), or send your own "values".
await hk.vectors.upsert(collection.id, [
{ id: 'doc-1', text: 'Peaches are in season in July.', metadata: { lang: 'en' } },
{ id: 'doc-2', text: 'Mangoes ripen best at room temperature.', metadata: { lang: 'en' } },
]);
const { matches } = await hk.vectors.query(collection.id, {
text: 'when can I buy peaches?', topK: 3, filter: { lang: 'en' }, returnMetadata: 'all',
});Namespaces and filters
- Give each vector a
namespace(one per end user, say); a query that names the namespace sees only its vectors. - Filters work on metadata fields made filterable with
metadataIndexesat create time, or later withPOST /api/vectors/:id { action: "index-metadata", propertyName, indexType }. Only vectors written after a field is indexed are filterable. Filtering on a field that is not indexed is a 400 that says so. - Delete vectors with
POST /api/vectors/:id { action: "delete", ids: [...] }.
Limits
- Upsert up to 1,000 vectors per request (100 when sending text); metadata up to 10 KB per vector.
- topK up to 100, or 50 when returning full metadata or values.
- Upserts are applied in the background: allow a few seconds (up to about 30) before new vectors show in queries.
GET /api/vectors/:idreports the index's own count. - Collections per plan: 1 hobby, 10 pro, 200 enterprise.
- Keys: a key limited to the project works for everything here; a read-only key can query but not upsert or delete.
Reference
const { embeddings } = await hk.ai.embed(['fresh mango', 'ripe banana'], 'bge-base'); // 768 dims
await hk.vectors.upsert(collectionId, [{ id: 'a', values: embeddings[0], metadata: { title: 'Mango' } }]);
const { matches } = await hk.vectors.query(collectionId, { vector: embeddings[1], topK: 5 });Memory
"kv" is a key-value store with TTL, to the second (carts, sessions, flags). "conversation" is an ordered transcript: append turns and read the last N back to build the next prompt. "semantic" embeds every value as you write it and recalls by meaning, with similarity scores.
One store serves every user of your app: pass a scope (a user or session id) on each call. Values, transcripts and searches in one scope never mix with another's.
A multi-user chat
const base = 'https://harakumo.com/api/memory/' + storeId;
const headers = { authorization: 'Bearer ' + process.env.HARAKUMO_API_KEY, 'content-type': 'application/json' };
// Append this user's turn to their own transcript
await fetch(base + '/messages', { method: 'POST', headers, body: JSON.stringify({ role: 'user', content: text, scope: userId }) });
// Read their last 20 messages to build the next prompt
const { messages } = await (await fetch(base + '/messages?scope=' + encodeURIComponent(userId) + '&limit=20', { headers })).json();
// Forget one user on request
await fetch(base + '/messages?scope=' + encodeURIComponent(userId), { method: 'DELETE', headers });Scopes
- A scope is 1–64 characters: letters, digits and . _ - : @ + = (so a user id or an email address fits as-is).
- Pass it in the query string, or in the body of a PUT or POST.
- A semantic search only matches values written in the same scope; a search with no scope sees only unscoped values.
PATCH /api/memory/:id { action: "flush", scope }clears one scope; without scope it clears the store.
Limits
- Values up to 1 MB; keys up to 512 characters.
- A conversation keeps the 1,000 most recent messages per scope; one read returns up to 500.
- Semantic values become searchable about 30 seconds after they are written — a search right after a write can legitimately find nothing, and the response says so.
- Each plan caps items per store and bytes per workspace for every kind of store; a write past the cap gets a 402 naming it. Expired values are removed nightly.
- Semantic writes and searches are metered on AI credits (embedding), and searches share the embeddings rate limit.
- Each semantic write, delete and search is one call from the workspace's 600 per 5 minutes, shared with databases and vectors. Flushing a semantic scope removes up to 1,000 values per call and answers
partial: trueuntil it is done; run it again. - Keys: a key limited to the project works for everything here; a read-only key can read and search but not write.
Reference
const { store } = await hk.memory.create(project.id, { name: 'session', kind: 'kv' });
await hk.memory.set(store.id, 'cart', { items: 3 }, { ttlSeconds: 3600, scope: 'user-42' });
const { store: notes } = await hk.memory.create(project.id, { name: 'notes', kind: 'semantic' });
await hk.memory.set(notes.id, 'fact-1', 'Peaches cost $4 per pound.');
const { matches } = await hk.memory.search(notes.id, { query: 'what does fruit cost?', topK: 3 });
// Scoped reads, searches and transcripts: pass ?scope= over REST (below).AI Gateway
Call POST /api/ai/chat with a model and messages. Every answer reports the model that answered, the provider, tokens, latency, why it stopped and exactly what it cost. Usage is paid from the workspace's included AI credit first, then the Balance.
harakumo-1 (alias auto) is a router: it tries vendor models in order within a tier (fast, balanced or deep) until one answers, and is billed at the price of the model that answered — Claude Sonnet leads the balanced tier. Name a model to pin it; llama-3.3-70b runs on Harakumo's edge inference. Some models are off until an admin turns them on in the AI Gateway settings.
Chat
curl https://harakumo.com/api/ai/chat \
-H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
-d '{ "model": "harakumo-1", "system": "Answer in one sentence.",
"messages": [{ "role": "user", "content": "What is an edge function?" }], "max_tokens": 300 }'
# → { "output", "modelUsed", "provider", "finishReason", "truncated", "tokensIn", "tokensOut", "cost", "latencyMs" }const res = await fetch('https://harakumo.com/api/ai/chat', {
method: 'POST',
headers: { authorization: 'Bearer ' + process.env.HARAKUMO_API_KEY, 'content-type': 'application/json' },
body: JSON.stringify({ model: 'claude-sonnet-5', stream: true, messages: [{ role: 'user', content: 'Tell me a story.' }] }),
});
// Events: "meta" (the model answering), "delta" ({ text }), then "done"
// ({ finishReason, truncated, tokensIn, tokensOut, cost }) or "error".
for await (const chunk of res.body) process.stdout.write(new TextDecoder().decode(chunk));OpenAI-compatible
Point any OpenAI client at https://harakumo.com/api/ai/v1 with a Harakumo key. The request and response shapes are the same (streaming included), model is any Harakumo model id, and each reply carries a harakumo field with the provider, cost and any fallback taken.
import OpenAI from 'openai';
const ai = new OpenAI({ apiKey: process.env.HARAKUMO_API_KEY, baseURL: 'https://harakumo.com/api/ai/v1' });
const r = await ai.chat.completions.create({ model: 'harakumo-1', messages: [{ role: 'user', content: 'Hi' }] });
console.log(r.choices[0].message.content);Request fields
| Field | Meaning |
|---|---|
| model | A model id, or harakumo-1 / auto. GET /api/ai/chat lists the models this workspace can use and its limits. |
| messages or prompt | A chat history (system, user, assistant roles), or one prompt string. |
| system | A system prompt. |
| max_tokens | Default 1,024, up to 8,192 (2,048 on edge models). |
| temperature | 0–2 (Claude models are capped at 1). |
| tier | For harakumo-1: fast, balanced or deep. |
| stream | true for server-sent events. |
| projectId | Attribute the spend to a project (or send an X-Harakumo-Project header). |
An answer that stopped at max_tokens has truncated: true and a note on how to get the rest.
Embeddings
POST /api/ai/embeddings { input, model } returns vectors from bge-small (384 dimensions), bge-base (768, the default) or bge-large (1,024), up to 100 inputs per call. They pair with Vector DB collections of the same size.
Money, limits and logs
- Every plan includes AI usage each month ($1 hobby, $10 pro, $100 enterprise; Hobby only on the personal workspace, and vendor models need the owner's verified email). Then the Balance pays, at each model's list price with no markup.
- When included credit and Balance are both gone, calls return 402
ai_credits_exhausted; past the workspace's spending limit, 402spend_limit_reached. Nothing is charged for a refused call. - Rate limit per workspace: 60 a minute on hobby, 600 a minute on pro, 6,000 a minute on enterprise; chat and embeddings have separate buckets. Over it: 429
ai_rate_limitedwith Retry-After. - Input up to 200,000 characters per request (413 over it).
GET /api/ai/requestslists recent calls (model, tokens, cost, speed — never the prompt or answer), kept 30 days, with this month's spend by model and project.- The AI Gateway and embedding endpoints are workspace-wide: use a workspace key, not a project key.
Reference
// One-shot prompt (the playground endpoint):
const answer = await hk.ai.generate({ model: 'harakumo-1', prompt: 'Summarize edge functions.' });
console.log(answer.output, answer.modelUsed, answer.cost);
// Embeddings for Vector DB (bge-small 384 · bge-base 768 · bge-large 1024):
const { embeddings, dimensions } = await hk.ai.embed(['fresh mango', 'ripe banana'], 'bge-base');
// Chat history, system prompts and streaming: POST /api/ai/chat or the OpenAI-compatible URL.Agents
An agent calls tools in a loop until the task is done or it reaches its step limit. The tools are Harakumo's own actions (list projects, read usage, query vectors … and, when allowed, deploy, create or delete). They run with a temporary developer key made for the run and revoked afterwards.
Agents are for acting on your own Harakumo resources today. Custom HTTP tools, memory attached to an agent and background runs with a callback are not available yet.
Create and run
const { agent } = await hk.agents.create(project.id, {
name: 'ops', model: 'claude-sonnet-5',
instructions: 'Report failed deploys and projects close to their plan limits.',
tools: ['list_projects', 'get_usage', 'list_deployments'],
maxSteps: 8,
});
const { run } = await hk.agents.run(agent.id, 'What needs attention today?');
// run.status, run.output, run.steps, run.cost_micros, run.model, run.stop_reasonSettings
| Field | Meaning |
|---|---|
| model | A tool-capable model (Claude, GPT, Grok, Kimi) or auto (runs on claude-sonnet-5). |
| tools | Tool names from GET /api/agents/tools (add ?writes=1 to see the ones that change things). |
| allowWrites | false by default: only read tools. true lets the agent use tools that change things — deploy, create, delete, send mail, spend the Balance on domains. Turn it on deliberately. |
| maxSteps | 1–24, default 8. A run that hits it stops as failed with stop_reason step_limit. |
| instructions | Up to 16,000 characters. |
Limits and cost
- Each step is charged as it happens at the model's price, from included AI credit and then the Balance; a run stops when the money or the spending limit runs out.
- Runs: 6 a minute per workspace; 1 at once on hobby, 5 at once on pro, 20 at once on enterprise. Input up to 32,000 characters.
- A run finishes inside the HTTP request; a run still marked running after 60 minutes is marked failed (stale).
- Edit an agent with
PATCH /api/agents/:id(model, instructions, tools, maxSteps, allowWrites); its run history is kept.
Reference
const { agent } = await hk.agents.create(project.id, { name: 'triage', model: 'auto', tools: ['list_projects'] });
const { run } = await hk.agents.run(agent.id, 'List my projects and which are live.');
const { agent: a, runs } = await hk.agents.get(agent.id); // the 20 most recent runs with their stepsMedia
Create a video asset, upload the file to its one-time upload URL, and play it over HLS when it is ready. Live channels get an RTMPS ingest URL and a stream key; point your encoder at it and go live.
Details
- Video only, up to 1 hour per upload. Kinds "image" and "audio" answer 501 — keep those files in Storage.
- Status runs pending_upload → processing → ready. Size and duration appear once the file is processed.
- An expired upload URL is replaced with a fresh one (
POST /api/media/:id/upload); once a file has arrived, no new URL is issued. - Stream keys and upload URLs are visible to developers and above, never to viewers.
- Live channels do not record.
- Assets per plan: 5 hobby, 100 pro, 1,000 enterprise.
Reference
const { asset, uploadUrl } = await hk.media.create(project.id, { name: 'promo', kind: 'video' });
// POST multipart/form-data with field "file" to uploadUrl, then poll:
const { asset: ready } = await hk.media.get(asset.id); // pending_upload → processing → ready
const live = await hk.media.create(project.id, { name: 'launch', kind: 'live' }); // ingest URL + stream keyAuth pools
Each project can have one pool. End users sign up and log in, Harakumo stores them (passwords hashed with PBKDF2) and returns a signed token your app checks on each request.
Enabling a pool returns three values. The client secret (as_…) lets your server act for this pool only; it is shown once and can be rotated. The JWT secret (js_…) verifies tokens; developers can show it again. The client id (ap_…) is publishable and keys the browser endpoints.
Two ways to call it
| From | Endpoint | Credential |
|---|---|---|
| Your server | POST /api/auth-pools/:poolId with an action | Bearer client secret (as_…), or an API key |
| A browser or mobile app | POST /api/auth-pools/public/:clientId/:action | None — the client id is in the URL. Off until you turn it on. |
Enable a pool
curl -X POST https://harakumo.com/api/projects/<projectId>/auth \
-H "Authorization: Bearer $HARAKUMO_API_KEY"
# 201 → { "pool": {…}, "clientId": "ap_…", "clientSecret": "as_…", "jwtSecret": "js_…",
# "serverEndpoint", "browserEndpoint", "verifyEndpoint", "algorithm": "HS256" }Store clientSecret and jwtSecret as environment variables of the project (for example HK_CLIENT_SECRET and HK_JWT_SECRET), with the pool id, client id and issuer. The issuer is https://harakumo.com/auth/<project-slug>.
Sign-up and login from your server
Every call names its action: signup, login, send-verification, verify-email, forgot-password or reset-password. Your server keeps the returned token in its own cookie and verifies it on each request. The example uses express and jose (npm install express jose).
import express from 'express';
import { jwtVerify } from 'jose';
const POOL = 'https://harakumo.com/api/auth-pools/' + process.env.HK_POOL_ID;
const secret = new TextEncoder().encode(process.env.HK_JWT_SECRET);
async function pool(body) {
const r = await fetch(POOL, {
method: 'POST',
headers: { authorization: 'Bearer ' + process.env.HK_CLIENT_SECRET, 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const data = await r.json();
if (!r.ok) throw Object.assign(new Error(data.error), { status: r.status });
return data; // { user, token, expiresIn }
}
const app = express();
app.use(express.json());
app.post('/signup', async (req, res) => {
try {
const { token } = await pool({ action: 'signup', email: req.body.email, password: req.body.password });
res.cookie('session', token, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 7 * 864e5 }).json({ ok: true });
} catch (err) {
res.status(err.status || 500).json({ error: err.message }); // 409 when the email already has an account
}
});
app.post('/login', async (req, res) => {
try {
const { token } = await pool({ action: 'login', email: req.body.email, password: req.body.password });
res.cookie('session', token, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 7 * 864e5 }).json({ ok: true });
} catch (err) {
res.status(err.status === 429 ? 429 : 401).json({ error: err.message });
}
});
// Verify locally on every request: no call to Harakumo.
async function requireUser(req, res, next) {
const token = (req.headers.cookie || '').match(/(?:^|; )session=([^;]+)/)?.[1];
try {
const { payload } = await jwtVerify(token || '', secret, {
algorithms: ['HS256'], issuer: process.env.HK_ISSUER, audience: process.env.HK_CLIENT_ID,
});
req.user = payload; // sub, email, name, email_verified …
next();
} catch {
res.status(401).json({ error: 'Sign in first' });
}
}
app.get('/me', requireUser, (req, res) => res.json({ id: req.user.sub, email: req.user.email }));
app.listen(process.env.PORT || 3000);Token claims
| Claim | Value |
|---|---|
| iss | The pool's issuer, https://harakumo.com/auth/<project-slug> |
| aud | The pool's client id |
| sub | The end user's id |
| email, name | As signed up |
| email_verified | true once they entered a verification code |
| pool | The pool id |
| tv | Token version: moves on when the user is reset or signed out everywhere |
| iat, exp | Issued and expiry; tokens last 7 days (HS256) |
Local verification cannot see a revocation until the token expires. POST /api/auth-pools/:id/verify { token } also refuses tokens of deleted users and users signed out everywhere (reason "revoked"); call it where that matters.
Sign-in from the browser or a mobile app
- Turn browser access on:
PATCH /api/auth-pools/:id/settings { publicEnabled: true }. Letting anyone create an account is a separate switch,publicSignup: true. - The project's own addresses and verified custom domains are allowed automatically. Add other origins with
allowedOrigins(https only; http only for localhost; up to 20). A request from any other Origin is refused before anything runs. - Call the public endpoints with the client id.
GET …/sessionwithAuthorization: Bearer <token>answers who is signed in.
const AUTH = 'https://harakumo.com/api/auth-pools/public/ap_…';
const r = await fetch(AUTH + '/login', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const { token, user, error } = await r.json();
const me = await (await fetch(AUTH + '/session', { headers: { authorization: 'Bearer ' + token } })).json();Browser limits: sign-up 10 per IP an hour and 600 per pool an hour; login 30 per IP per 10 minutes; codes 20 per IP an hour.
Email verification and password reset
send-verification { email }emails a 6-digit code;verify-email { email, code }marks the address verified and returns a fresh token.forgot-password { email }emails a reset code;reset-password { email, code, password }sets the new password, signs the user out everywhere else and returns a token.
- Codes last 15 minutes and allow 5 tries; at most 5 codes an hour per address.
- They are sent under your app's sender name and count toward the workspace's email quota.
- Browser answers never reveal whether an email has an account.
Managing users and secrets
GET /api/auth-pools/:id?q=&before=&limit=lists users (up to 200 a page).PATCH /api/auth-pools/:id/users/:userIdwith action reset-password (with password), send-password-reset, revoke-sessions or unlock.DELETEremoves the user; their tokens stop verifying.GET /api/auth-pools/:id/secretshows the JWT secret again (developer and up, audited).POST /api/auth-pools/:id/secret { which: "jwt" }rotates it, keeping the old one valid for 24 hours (orkeepPrevious: falseto revoke at once).{ which: "client" }replaces the client secret immediately. Rotating needs an admin.
Limits and what is not available yet
- Passwords 8–256 characters. 10 wrong passwords lock the account for 15 minutes (429 with Retry-After).
- No social login, no end-user MFA, no hosted sign-in page, no refresh tokens and no RS256/JWKS yet.
- Pools per plan: 1 hobby, 10 pro, 100 enterprise; end users per pool are not capped.
- Verifying tokens locally scales with your app. Sign-ups and logins run on the shared platform and suit apps with thousands of active users.
Reference
// On your server, the client secret works as the SDK key — for this pool's sign-in actions only.
const pool = new Harakumo({ apiKey: process.env.HK_CLIENT_SECRET });
const { token } = await pool.auth.signup(poolId, { email: 'user@yourapp.com', password: 'correct-horse-9' });
const { token: t2 } = await pool.auth.login(poolId, { email: 'user@yourapp.com', password: 'correct-horse-9' });
const { valid, claims, reason } = await pool.auth.verifyToken(poolId, t2);
// Managing the pool needs an API key:
const { pool: p, clientId, clientSecret, jwtSecret } = await hk.auth.enable(project.id);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
- Enable payments on the project — one call, live at once.
- Create a checkout link for an amount, with your own reference and metadata and a successUrl on your site.
- Send the buyer to the link. It can be paid once and expires after 24 hours.
- After paying, the buyer returns to your successUrl with
session_idadded. - Your server confirms the payment with the session lookup, or receives the payment.succeeded webhook.
- 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 |
| 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 -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" }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).
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.
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);
});{
"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-setupreturns an identity-check link), thenwithdrawsends what is available. 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/:accountIddisables payments: no new links, but earnings, history and the payout account are kept and links already sent are still credited.POST /api/projects/:id/paymentsturns it back on.?permanent=trueerases 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
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).Every email carries your app's name and comes from, in order: the from address on the email, the project's From address, or no-reply@harakumo.com. Recipients see your project as the sender — "Vertex <hello@vertex.app>" or "Vertex <no-reply@harakumo.com>", never Harakumo.
The name comes from, in order: fromName on the email, the project's saved sender name, the project's name, then a team workspace's name. A workspace with several projects must pass projectId.
Send
curl -X POST https://harakumo.com/api/mail/send \
-H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
-d '{ "to": "user@yourapp.com", "subject": "Welcome", "text": "Hello!", "projectId": 12 }'
# 200 → { "message": { "status": "sent", "from_name": "Vertex", "from_email": "no-reply@harakumo.com", "message_id": "…" },
# "from_fallback": null }
# From your own verified domain, with a Reply-To (both optional)
curl -X POST https://harakumo.com/api/mail/send \
-H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
-d '{ "to": "user@yourapp.com", "subject": "Welcome", "text": "Hello!", "projectId": 12,
"from": "hello@yourdomain.com", "replyTo": "support@yourdomain.com" }'Mail to reserved test domains (example.com, .test, .invalid, .localhost) is never delivered: it is logged with status simulated. Test with a real address.
What the statuses mean
| Status | Meaning |
|---|---|
| sent | The provider accepted the message. Delivery, bounces and opens are not reported yet. |
| simulated | Not sent: a reserved test address, or no provider in this environment. |
| failed | The provider refused it; the response is 502 with the reason. Failed sends do not count toward the quota. |
Sender names
- Set one per project on the Mail page (Sender names & addresses) or with
PATCH /api/projects/:id { mailSenderName }("" resets it), or per email withfromName. - Up to 64 characters. Names that look like Harakumo, or are only generic words ("Support", "No Reply"), are refused, and subjects may not mention Harakumo.
Send from your own domain
Add a domain you own once, and your apps can send from any address on it, like hello@yourdomain.com. How much you do depends on where the domain's DNS lives. Harakumo finds out for you when you add it.
A subdomain like mail.yourdomain.com works too. Sending domains per workspace: 1 on hobby, 10 on pro, 50 on enterprise.
| You have | What happens |
|---|---|
| A domain bought on Harakumo | Nothing to copy. Harakumo adds every email record for you. If its DNS hosting is off, turn it on first (Domains page, or host_domain_dns from an AI assistant). |
| A domain connected to a project, nameservers already switched | Nothing to copy. Harakumo adds every email record for you and it is verified within minutes. |
| A domain connected to a project, nameservers not switched yet | Switch the nameservers and sending turns on by itself. Or add the records yourself at your current provider instead. |
| A domain at another company (GoDaddy, Namecheap, Squarespace, Hostinger …) | You add 3 required records (and 3 recommended ones) at that company. About 5 minutes of copying; Harakumo shows exactly what to copy and checks each record for you. |
Until a domain is verified, your mail still goes out from no-reply@harakumo.com under your app's name, so nothing breaks while you set it up. Mail is never sent from an unverified domain.
If the domain's DNS is on Harakumo
- Open Mail → Sending domains and press Add a domain. Type the domain (for example yourdomain.com) and press Continue.
- Harakumo sees the DNS is here and says "Good news … nothing to copy". Press Set it up.
- Harakumo adds the email records to your DNS and confirms them. This usually takes a few minutes; the page updates by itself and the workspace owner gets an email.
- When it shows Verified, choose an address like hello@yourdomain.com for a project under Sender names & addresses.
If the card says Waiting for nameservers, the domain is set up on Harakumo but its nameservers still point at your old provider. Replace them at the company where you bought the domain with the two shown on the Domains page. Sending turns on by itself once they switch (usually within an hour, sometimes up to a day).
If the domain's DNS is at another company
- Open Mail → Sending domains and press Add a domain. Type the domain and press Continue, then choose Add the records yourself.
- Sign in to the company where you manage DNS for the domain (usually where you bought it) and open its DNS settings. The card tells you who that probably is ("Looks like GoDaddy manages DNS for yourdomain.com").
- Add the 3 required records. Copy each Type, Host and Value exactly; every cell has a copy button. The 3 recommended records help your mail reach inboxes, so add them too if you can.
- Save, come back and press Check now. The card says how far along you are ("2 of 3 required records found") and marks each record Found, Missing or Wrong.
- Most providers take a few minutes; some take up to a few hours. After you press Check now the page re-checks every minute for 10 minutes, and Harakumo keeps checking every 10 minutes in the background.
- When all required records show Found, the domain is verified (the owner gets an email). Choose an address on it for a project, or pass
fromwhen you send.
Add the records within 3 days of starting. After that the setup stops and Start again gives you fresh records. Rather not edit DNS? A domain bought on Harakumo can have its DNS hosted here in one step, and a domain you are already connecting to a project only needs its nameservers switched.
The records, in plain words
The Host is the part before your domain: <token>._domainkey means <token>._domainkey.yourdomain.com. For a subdomain like mail.yourdomain.com, each Host ends in .mail. The exact values are unique to your domain and are shown in the dashboard, the API response and the CLI.
- The SPF and MX records go on
bounces.yourdomain.com, so they never clash with the email you already receive at yourdomain.com or with its own SPF record. - Already have a DMARC record at
_dmarc? Keep yours and skip ours: two DMARC records switch DMARC off. Any DMARC record counts as Found. - When Harakumo hosts the DNS it writes these records itself and never changes or removes a record you made.
| Type | Host | Value | What it does | Needed? |
|---|---|---|---|---|
| CNAME (3 of them) | <token>._domainkey | shown in the dashboard | Proves the mail really comes from you (DKIM). | Required |
| MX | bounces | shown in the dashboard, Priority 10 | Handles bounced mail. | Recommended |
| TXT | bounces | shown in the dashboard (starts v=spf1) | Lets Harakumo send for your domain (SPF). | Recommended |
| TXT | _dmarc | v=DMARC1; p=none; | Tells inboxes what to do with fakes (DMARC). | Recommended |
Where to add them, provider by provider
| Provider | What to do |
|---|---|
| GoDaddy | Sign in, open My Products, find your domain and choose DNS. Choose Add New Record and pick the Type. In Name, paste the Host: GoDaddy adds your domain for you, so leave it off. Paste the Value (for CNAME it is called Points to), keep TTL as is, and Save. For the MX record, use Priority 10. |
| Namecheap | Sign in, open Domain List and choose Manage next to your domain. Open Advanced DNS and choose Add New Record. Pick the Type, paste the Host, and paste the Value (for CNAME it is called Target). Save each record with the check mark. The MX record is optional. Skip it if your email uses Namecheap Email Forwarding or Private Email, because changing Mail Settings turns those off. Otherwise, to add it, set Mail Settings to Custom MX and add it there with Priority 10. |
| Squarespace (domains that were at Google Domains are here now) | Open Domains in your Squarespace account and choose your domain. Go to DNS, then DNS Settings, and scroll to Custom records. Choose Add record, pick the Type, paste the Host and the Value (called Data), and Save. For the MX record, use Priority 10. |
| Hostinger | Open Domains in hPanel, choose your domain, then DNS / Nameservers. Under Manage DNS records, pick the Type. In Name, paste the Host. In Points to (or Target, or TXT value), paste the Value. Keep TTL as is and choose Add Record. For MX, use Priority 10. |
| Any other provider | Find the page called DNS, DNS zone or Advanced DNS. Add one record for each row: same Type, Host and Value. If it asks for the full name, turn on Show full names and use that instead of Host. A dot added at the end of a value is fine. If the record has a proxy or CDN switch, turn it off (DNS only). Stuck? Send the records to your provider's support and ask them to add them. |
The dashboard picks your provider for you from the domain's nameservers; harakumo mail domains show yourdomain.com --provider godaddy prints the same steps (namecheap, squarespace, hostinger, other).
Checking, and what each status means
Check now (or POST /api/mail/domains/:id/check, or the check_mail_domain tool) looks each record up in public DNS right away and answers with every record's status. Checks less than 10 seconds apart return the last result. In the background, domains still being set up are checked every 10 minutes and verified ones every 6 hours.
- Each record is
found,missingorwrong. A wrong record also lists what public DNS shows instead (seen), so you can compare it with the Value. records_summary.textis the one-line progress, like "2 of 3 required records found".next_actionis one ofadd_records,fix_records,wait,point_nameservers,wait_for_platform,restartornone, andnextsays it in a sentence.
| Status | can_send | Meaning |
|---|---|---|
| pending | false | Being set up: records are missing or being confirmed. next_action says what, if anything, you need to do. |
| verified | true, or false while reason_code is set | Ready. Your apps can send from any address on the domain. A verified domain with a reason_code (for example provider_sandbox) is waiting on Harakumo, not on you. |
| failed | false | Stopped. reason says why; Start again (or add it again) gets fresh records. |
| removed | false | You removed it. It no longer counts toward your plan. |
Record not found?
- Give it time. New records usually show within minutes, but some providers take a few hours. Press Check now again later; nothing is lost while you wait.
- The domain appears twice. Many providers add your domain to the Host automatically, so
<token>._domainkey.yourdomain.combecomes<token>._domainkey.yourdomain.com.yourdomain.com. The card says "Your DNS provider added yourdomain.com a second time". Use only the Host shown. - Marked Wrong. The record exists but its value differs: compare the "We see" line with the Value column. Extra quotes or a dot at the end are fine; a missing character or a value cut short is not.
- Proxy or CDN switch. If your provider has one on the record (sometimes called Proxied), turn it off so the record is DNS only.
- Added at the wrong company. DNS is managed wherever the domain's nameservers point, which is not always where you bought it. The card's "Looks like … manages DNS" tells you where to go.
- Setup stopped after 3 days, or the domain says it expired. Press Start again, replace the old records with the fresh ones shown, and check again.
- Still stuck? Send the records to your DNS provider's support and ask them to add them exactly as shown.
When a domain can't send
A send asking for an address on a domain that cannot send yet still goes out, from no-reply@harakumo.com under your app's name. The response's from_fallback says why: { requested, reason_code, reason }. It is null when the address you asked for was used.
An explicit from on a domain this workspace never added is refused (400 from_domain_not_added) rather than sent from the shared address.
| reason_code | What it means | What to do |
|---|---|---|
| records_missing | Some required records are not visible yet. | Add them at your DNS provider, then press Check now. |
| records_wrong | A record is there but its value does not match, or its Host has the domain added twice (some providers add it for you). | Fix it to match the Host and Value exactly, then press Check now. |
| zone_pending | The domain is set up on Harakumo but its nameservers do not point here yet. | Switch the nameservers (a domain bought on Harakumo with DNS hosting on is switched for you). Sending turns on by itself. |
| provider_pending | Everything is in place; the final confirmation is running. | Nothing. It usually takes a few minutes. |
| edge_not_permitted | Harakumo is finishing setup on its side for domains hosted here. | Nothing. The page updates by itself and this never expires. |
| provider_sandbox | The domain is verified; sending from it opens once Harakumo's platform approval completes. | Nothing. |
| expired | The records were not found within 14 days. | Start again for fresh records. |
| verification_failed | The records were not found within 3 days, so the setup stopped. | Start again, add the fresh records, and check again. |
| verification_lost | It was verified, but its records are gone. | Put the records back, then press Check now. |
| domain_left_workspace | The domain's DNS is no longer hosted on Harakumo by this workspace. | Add it again to set it up at its new DNS provider. |
| replaced | Another workspace started setting up the domain before its records were added here. | If it is yours, press Start again, then add the fresh records shown. |
| domain_claimed_elsewhere | Another workspace on Harakumo now hosts or holds this domain. Controlling its nameservers wins over records. | If it is yours, contact support@harakumo.com. |
| domain_removed | The project's saved From address is on a domain this workspace no longer has (only in from_fallback). | Pick a new From address, or add the domain again. |
Choosing the From address and Reply-To
- Per project: on the Mail page under Sender names & addresses, or
PATCH /api/projects/:id { mailFrom, mailReplyTo }("" resets either). A domain that is still pending can be chosen in advance; mail uses no-reply@harakumo.com until it is verified. - Per email:
fromandreplyToon/api/mail/send(the SDK, CLI andsend_emailtool take the same). - The part before @ uses lowercase letters, numbers and . _ + - (up to 64 characters). postmaster@, abuse@, hostmaster@ and mailer-daemon@ are reserved for mail servers.
- On the shared domain only no-reply@harakumo.com can be used. Add your own domain for any other address.
- Reply-To is one address and needs no verification, but it cannot be a Harakumo address.
- A domain belongs to one workspace. Once a workspace has verified yourdomain.com, other workspaces cannot send from it or from names under it. A setup another workspace started but never added records for does not block you: adding it takes over.
- Removing a domain (Remove on its card, or
DELETE /api/mail/domains/:id) moves projects that used it back to no-reply@harakumo.com; their sender names stay. Records Harakumo added are removed; records you added at another provider can then be deleted there.
Set it up with an AI assistant
With the connector attached, ask: "Set up yourdomain.com as a sending domain on Harakumo and walk me through it." The assistant adds the domain, shows you the steps and the exact records, checks them while you add them, and sets your project's From address once it is verified.
add_mail_domain— start (or start again). Returns the method, the records, the numbered steps and provider hints.check_mail_domain— check DNS now; returns each record's status and "2 of 3 required records found".list_mail_domains/get_mail_domain— status, records and every project's current From address.set_mail_from— a project's From address and Reply-To.remove_mail_domain— stop sending from a domain.host_domain_dns— for a domain bought on Harakumo, host its DNS here so every record is added for you.send_email— acceptsfromandreplyTo.
From code: API, SDK and CLI
# 1. Add the domain
curl -X POST https://harakumo.com/api/mail/domains \
-H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
-d '{ "domain": "yourdomain.com" }'
# 201 → { "created": true, "domain": { "id": 7, "domain": "yourdomain.com", "method": "external", "status": "pending",
# "can_send": false, "records_summary": { "text": "0 of 3 required records found", … },
# "records": [{ "type": "CNAME", "host": "<token>._domainkey", "value": "<value shown>", "required": true, "status": "missing" }, …],
# "steps": ["Sign in to the company where you manage DNS for yourdomain.com …", …], "next_action": "add_records", … } }
# 2. After adding the records at your DNS provider
curl -X POST https://harakumo.com/api/mail/domains/yourdomain.com/check -H "Authorization: Bearer $HARAKUMO_API_KEY"
# 3. Once verified, make it the project's From address
curl -X PATCH https://harakumo.com/api/projects/12 \
-H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
-d '{ "mailFrom": "hello@yourdomain.com", "mailReplyTo": "support@yourdomain.com" }'const { domain } = await hk.mail.domains.add('yourdomain.com'); // domain.records, domain.steps
// … add the records at your DNS provider …
await hk.mail.domains.check('yourdomain.com'); // records_summary.text, status, can_send
await hk.mail.domains.waitUntilVerified('yourdomain.com'); // polls every 30 s, up to 10 minutes
await hk.mail.setFrom(project.id, { from: 'hello@yourdomain.com', replyTo: 'support@yourdomain.com' });harakumo mail domains add yourdomain.com # steps and the records to copy
harakumo mail domains show yourdomain.com --provider godaddy
harakumo mail domains check yourdomain.com --wait
harakumo mail from 12 hello@yourdomain.com --reply-to support@yourdomain.comQuotas and controls
- 100 on hobby, 5,000 on pro, 100,000 on enterprise, counted over a rolling 30 days (402
plan_limit). The owner is emailed at 80% and 100%. - Custom sending domains: 1 on hobby, 10 on pro, 50 on enterprise (402
plan_limit). Pending and failed domains count until you remove them. - Your own controls at
/api/mail/settings(admins):pausedstops every send (403mail_paused),dailyCaplimits any 24 hours (429daily_cap). - A suppression list at
/api/mail/suppressions: a suppressed address is refused with 422suppressedbefore anything is sent — record unsubscribes there. - One recipient per call, text and/or HTML, with an optional Reply-To. No attachments, cc, templates or batch sending yet.
- Mail is workspace-wide: use a workspace key, not a project key.
Reference
await hk.mail.send({ to: 'user@yourapp.com', subject: 'Welcome', text: 'Hello!', projectId: project.id });
await hk.projects.update(project.id, { mailSenderName: 'Vertex' }); // set once
await hk.mail.send({ to, subject, html, projectId: project.id, fromName: 'Vertex Billing' }); // or per email
const { messages } = await hk.mail.messages();
// Your own domain
await hk.mail.domains.add('yourdomain.com');
await hk.mail.domains.check('yourdomain.com');
await hk.mail.setFrom(project.id, { from: 'hello@yourdomain.com', replyTo: 'support@yourdomain.com' });
const { from_fallback } = await hk.mail.send({ to, subject, text, projectId: project.id, from: 'hello@yourdomain.com', replyTo: 'support@yourdomain.com' });Domains
Every domain in the workspace is one entry, keyed by its root (GET /api/domains), with one plain-word state and the next step. www is served together with the apex.
Buy, connect or transfer?
| You want to | Do this |
|---|---|
| A new name | Buy it here. It is paid from the Balance or covered by your plan's included domains, and can connect to a project in the same call. |
| Use a name you own elsewhere | Connect it: add it to a project and switch its nameservers to the two Harakumo gives you. |
| Move a name you own to Harakumo | Transfer it in with its auth code; the price includes a year of renewal. |
Buying
- Search:
GET /api/domains/search?q=myshop(ormyshop.orgto search that name first). Each result hasavailable(null means it could not be checked — not taken),sellable,price_centsandrenewal_price_cents. - Buy:
POST /api/domains/registrations { domain, projectId? }. The live price is checked again; if it is above the price you saw (expectedPriceCents) nothing happens. - With a projectId the domain connects automatically and goes live within minutes to hours as DNS settles; the owner gets an email when it is live.
- Endings sold today: .com, .dev, .app, .io, .ai, .cloud. Automatic purchases above $100 a year are refused.
- Included domains: Pro includes 1 standard domain a year and Enterprise 5 (a standard domain costs $15 or less). Extra domains, premium names and every domain on Hobby are paid from the Balance. There is no cap on how many you buy — each is paid for.
- A Balance too small answers 402
insufficient_balancewith the shortfall and where to add funds; nothing is charged. The card on file is charged only when a signed-in owner asks (payWithCard: true). - Purchases count toward the workspace's monthly spending limit, if one is set.
- Harakumo registers the domain through its registrar account and manages it for your workspace. Registries lock a domain for 60 days after registration or transfer.
Connecting a domain you own
- Add it to a project:
POST /api/projects/:id/domains { hostname, connect: true }. Harakumo checks no other workspace owns that root domain. - Harakumo copies the records your domain answers with today (email included) into its DNS, and returns two nameservers. Review the copied records in DNS before switching.
- Set those nameservers at your registrar. The domain goes live on its own within about 10 minutes of the change being visible — there is nothing to press.
Switching nameservers moves all DNS for the root domain, even when you only connect a subdomain. Connecting by CNAME is not available yet.
DNS records
Any domain whose DNS Harakumo hosts — bought, transferred in or connected by nameservers — has editable records at /api/domains/zones/{root}/dns (GET, POST, PATCH, DELETE). Record types: A, AAAA, CNAME, MX, TXT and NS. SRV and CAA are not supported yet.
curl -X POST https://harakumo.com/api/domains/zones/myshop.com/dns \
-H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
-d '{ "type": "MX", "name": "@", "content": "mail.example.net", "priority": 10 }'Want email from this domain? Mail → Sending domains sets it up (automatically when its DNS is here).
Renewal
- Bought domains auto-renew by default (
autoRenew: falseat purchase to opt out). Renewal happens in the last 7 days before expiry, atrenewal_price_cents, paid from the Balance. Warnings go out 30, 7 and 1 days before. - The card covers a renewal only if the owner, signed in, turned on card backup (
autoRenewCard: trueat purchase, orPATCH { action: "auto-renew", enabled: true, cardBackup: true }). Renewals set up by developers, API keys or transfers pay from the Balance only. - A domain past expiry is marked expired; renewing it within 30 days points it back at its DNS.
Stop serving, DNS only, other nameservers
DELETE /api/domains/registrations/:id(orPATCH { action: "detach" }) stops serving the project. DNS, email and renewal stay.PATCH { action: "host-dns" }hosts DNS here without a project;{ action: "set-nameservers", nameservers: [...] }points the domain at another provider;{ action: "connect", projectId }serves another project.- Deleting a project detaches its domains; bought domains stay in the workspace and keep renewing.
Transfers
- In: unlock the domain at your current registrar, get its auth code, and
POST /api/domains/transfers { domain, authCode, dryRun: true }to validate and quote without paying. Then send it again without dryRun (withexpectedPriceCents). The Balance pays once; the transfer completes in 5–7 days. - Out:
PATCH /api/domains/registrations/:id { action: "transfer-out" }. The auth code arrives within five calendar days (usually sooner), by email and in the dashboard. The domain keeps working until it has moved.
Limits
- Custom domains connected per plan: 2 hobby, 20 pro, 500 enterprise.
- Up to 10 new DNS zones per workspace a day.
- Domain routes are workspace-wide: use a workspace key. Adding a hostname to a project (
/api/projects/:id/domains) accepts that project's key.
Reference
const { results } = await hk.domains.search('myshop'); // [{ domain, available, sellable, price_cents, renewal_price_cents }]
const pick = results.find(r => r.available && r.sellable);
const { registration, paidFrom } = await hk.domains.register(pick.domain, { projectId: project.id, expectedPriceCents: pick.price_cents });
// 402 insufficient_balance → err.body.shortfall_usd, err.body.add_funds_url
await hk.domains.add(project.id, { hostname: 'app.myshop.dev' }); // a domain you own; connect: see REST
const check = await hk.domains.transferIn('myshop.com', { authCode: 'EPP-CODE', dryRun: true });CDN & Shield
Both settings apply to every static site in the workspace and take effect on each project's next deploy. Volumetric attack protection runs at the network layer for every site and needs no setting.
Settings
| Setting | Meaning |
|---|---|
| CDN cacheTtlSeconds | How long browsers and the edge keep a page (default 3,600 seconds). Turning the CDN off stops caching: every visit is served fresh. |
| Shield blockedCountries | Two-letter country codes whose visitors are refused. |
Server apps and functions are not affected by these settings yet. Changing them needs an admin.
Reference
GET /api/services/cdn · PUT /api/services/cdn { "enabled": true, "cacheTtlSeconds": 3600 }
GET /api/services/shield · PUT /api/services/shield { "enabled": true, "blockedCountries": ["XX"] }Workspaces, roles & teams
A workspace (called an organization in some API paths) is the unit of billing and access: it has one plan, one Balance, one team and its own API keys, audit log and settings. Signing up creates your personal workspace; create more from the workspace switcher. How many you can own depends on the best plan among them.
Roles
| Role | Can |
|---|---|
| Owner | Everything, including the plan, cards, the Balance, spending limits, withdrawals and refunds, and deleting the workspace. |
| Admin | Members and invitations, API keys, workspace settings (CDN, Shield, AI Gateway models, mail controls), the audit log, reading billing, deleting projects. |
| Developer | Create, change and delete resources and deployments; read environment variables, function source and stream keys. |
| Viewer | Read only — no environment variables, secrets or function source. |
Team members
- Admins invite by email. The invitation is emailed, and accepting it needs a verified email at the invited address.
- Seats per plan: 3 hobby, 10 pro, 100 enterprise. Pending invitations hold a seat; a full workspace answers 402
plan_limit, and accepting re-checks. - Removing a member lists the API keys they created so you can revoke them.
What lives where
- In the workspace: the plan, Balance and cards, members, API keys, the audit log, domains bought, repos, the mail log and settings, and CDN, Shield and AI Gateway settings.
- In a project: its site or app, environment variables, custom domains, and its databases, buckets, functions, vector collections, memory stores, agents, auth pool, payment account and media.
Deleting a workspace or account
- Deleting a workspace cancels its subscription immediately (no proration refund) and tears down every project.
- It is refused while the workspace owns unexpired bought domains (transfer them out, or turn off auto-renew and let them lapse), has a domain transfer, refund or Balance top-up in progress, or holds project earnings.
- A positive Balance is given up, not refunded, and only when you say so: send
{ "confirmForfeit": true }(the first answer is 409confirm_forfeit_required). - Deleting your account (
DELETE /api/auth/me { password, confirmForfeit? }) applies the same rules to every workspace it removes.
Reference
GET /api/orgs # your workspaces
POST /api/orgs { "name" } # browser session
GET /api/orgs/:id/members · PATCH · DELETE # admin to change
POST /api/orgs/:id/invitations { "email", "role": "admin"|"developer"|"viewer" }
DELETE /api/orgs/:id { "confirmForfeit"? } # ownerSecurity & data isolation
Every request is checked the same way: who is calling, which workspace, what their role allows, and whether the resource belongs to that workspace's project. An id from another workspace answers 404 before any work happens.
Your account
- Two-factor sign-in: Settings → Account. Scan the code with an authenticator app, confirm a code, and keep the recovery codes. Sign-in then asks for a code (the API answers
{ mfaRequired, ticket }; complete withPOST /api/auth/login/mfa { ticket, code | recoveryCode }). Turning it off needs your password and a code. - Changing your email needs your current password (and a code when two-factor is on), then a code sent to the new address. The old address is told.
- A password reset or change signs out your other sessions and disconnects AI connectors.
- Sessions last 30 days; Settings lists them and signs the others out.
- Changes made with the dashboard cookie are refused when they come from another site.
Keys and connectors
- API keys can be read-only, limited to one project, or both (see API keys & authentication). Give each app the narrowest key that works.
- AI connectors are connected read-only or read-and-manage; a read-only connector cannot change anything. Changes still ask you first in the assistant.
- No key or connector can change your email, password or sessions, accept invitations, or move money out — those need you in a browser.
- Revoke keys in Settings → API access and connectors in Dashboard → Connectors; both take effect immediately.
How your data is kept apart
| Layer | Separation |
|---|---|
| Every API call | Your credential resolves to one workspace; resources are found through project → workspace, never by id alone. |
| Databases | Each database is its own SQLite database. SQL in one can never reach another. |
| Storage | Each bucket is its own bucket in object storage; a signed URL is for one key in one bucket. |
| Vector DB | One index per collection. |
| Memory | Rows in Harakumo's control database, keyed by store and scope; searches never cross scopes. |
| Functions and static sites | Each runs as its own isolated edge program with only its project's environment variables and no access to platform storage. |
| Server apps | Each deployment runs in its own container. |
| Auth pools | One per project, with its own signing secret. |
| Payments | Buyers pay into Harakumo's processor account; each project's share is tracked in a ledger, and withdrawals go only to the payout account the workspace owner connected. |
| Each workspace sends from no-reply@harakumo.com or from its own verified domains (a domain belongs to one workspace), with its own log and suppression list. | |
| Domains | Each root domain has one owning workspace; no other workspace can add a hostname under it. |
How secrets are stored
- Passwords (yours and your end users'): PBKDF2 hashes.
- Sessions, API keys, connector tokens and auth-pool client secrets: SHA-256 hashes; the plaintext is shown once.
- Two-factor recovery codes: hashed, and each works once.
- Environment variables and auth-pool JWT secrets: stored readable, because they are injected into deploys and used to sign tokens. Encryption at rest is planned.
Reference
POST /api/auth/login { "email", "password" } → session, or { mfaRequired, ticket }
POST /api/auth/login/mfa { "ticket", "code" | "recoveryCode" }
GET|POST|PUT|PATCH|DELETE /api/auth/mfa # status, set up, confirm, new recovery codes, turn off (browser session)
GET /api/auth/sessions · DELETE # sign out other sessions
GET /api/security/overview # the workspace's security checksBilling, Balance & AI credits
Each workspace has one plan and one Balance. The plan decides what you can create and what is included each month; the Balance pays for anything beyond. Money your customers pay you through Payments is Earnings, held per project — it never pays Harakumo and the Balance never pays you.
Plans
Limits & scaling lists every count. Plans limit how many things you create; data size, storage bytes and bandwidth have no plan quota yet.
| Hobby | Pro | Enterprise | |
|---|---|---|---|
| Price (per workspace) | Free | $20 a month | $500 a month |
| AI usage included | $1 a month (personal workspace only) | $10 a month | $100 a month |
| Domains included | None | 1 a year (up to $15 each) | 5 a year (up to $15 each) |
| Emails (rolling 30 days) | 100 | 5,000 | 100,000 |
Changing plan
- Only the owner changes the plan, from Dashboard → Billing or
POST /api/billing { plan }. - Upgrading charges the prorated difference now; the plan changes once that payment succeeds.
- Downgrading between paid plans starts at the next renewal. Moving to Hobby ends the subscription at the end of the period;
POST /api/billing { action: "resume" }(Keep plan) withdraws a pending cancel or downgrade. - Nothing is deleted on a downgrade; creating more than the new plan allows is refused.
- A missed subscription payment keeps the plan while the processor retries, with included AI paused; an unpaid subscription drops to Hobby limits until it is paid.
- If checkout says a plan's price is being updated, nothing was charged; try again later.
Included AI usage
- $1 on hobby, $10 on pro, $100 on enterprise each month. On Hobby it applies only to your personal workspace, and vendor models need the owner's verified email.
- It resets on the 1st at 00:00 UTC and does not carry over. It is used first; then the Balance pays.
- AI is priced at each model's list price (shown in the AI Gateway), with no markup. Every answer reports its exact cost.
Included domains
Pro includes 1 standard domain a year and Enterprise 5; a standard domain is one priced at $15 or less. Extra domains, premium names, and domains on Hobby are paid from the Balance. There is no limit on how many you buy.
The Balance
- Prepaid dollars the owner loads from Dashboard → Billing → Balance: $10 to $1,000 at a time, with the card on file or through checkout (which saves the card).
- It never expires and is not refundable. Deleting a workspace gives it up only after you confirm.
- It pays for AI beyond the included amount and for domain purchases, renewals and transfers beyond the included domains.
- A card is charged only when you choose: an owner paying with the card for one purchase (
payWithCard: true), auto-reload, or domain renewal card backup the owner turned on. - Every movement — funds added, AI usage, domains, refunds, plan invoices — is a transaction on the Billing page, with receipts for card charges.
Spending limit and alerts
- Set a monthly spending limit ($1 to $100,000; 0 for none) in Billing → Limits & alerts or
PATCH /api/billing/limits { spendLimitCents }(owner). It covers AI beyond the included amount plus domain purchases from the Balance or the card. Past it, requests answer 402spend_limit_reached. - Alerts (
alertLevels, default 50, 80 and 100 percent) email the owner once per level per month, for the included AI amount and for the limit. - Auto-reload charges the card a chosen amount when available AI credit drops below a threshold, at most 10 times a month. It fires only for requests from a signed-in person unless the owner sets
autoReloadForKeys: true.
PATCH /api/billing/limits
{ "spendLimitCents": 5000, "alertLevels": [50, 80, 100], "autoReloadForKeys": false }Seller earnings
Payments you take are Earnings on the project: each sale less the 3.9% + 30¢ fee, held 7 days, then withdrawn by the workspace owner. See Payments.
When things reset
| What | Clock |
|---|---|
| Included AI usage | The 1st of each month, 00:00 UTC |
| Spending limit, alerts, auto-reload count | Calendar month (UTC) |
| Email quota | Rolling 30 days |
| Subscription | Monthly from the day you subscribed |
| Included domains | Each year |
| Domain renewals | Each domain's own expiry date |
| Earnings hold | 7 days after each payment |
Who can see and change billing
- Admins and owners can see billing; only the owner can change the plan, cards, Balance, auto-reload and limits.
- API keys see the AI credit summary in
GET /api/usage(aiCredits) and cannot read or change billing. An owner's AI connector can read the Balance.
Reference
const { aiCredits } = await hk.usage.get();
// { balance_usd, available_usd, included_usd, included_used_usd, included_remaining_usd, resets_on, buy_url, auto_reload }
// Billing changes are made by the owner in the dashboard (an API key acts as developer).Usage, activity & audit log
GET /api/usage reports counts against plan limits, the AI credit position, and measured usage for a period. Dashboard → Analytics shows the same data, with a list of what is not measured yet.
Usage
?period=month, last_month, 7d, 30d (default) or 90d, in whole UTC days;?project=<id>for one project.- Returns
totalsper metric, a dailyseries,byProject, measured storage and database sizes (with when they were measured), deploy time and build-machine time, andnotMeasured. - Measured: resource counts, deploys and build time, database queries and rows read and written, storage bytes and object counts (daily), database sizes (daily), memory and vector operations, emails, end-user logins, checkout sessions, agent runs, AI requests, embeddings and AI cost.
- Not measured yet: requests and bandwidth to sites and apps, server-app running time, function-URL traffic, errors and latency.
What needs attention
GET /api/attention is a ranked to-do list: failed or stuck deploys, domains expiring or waiting on nameservers, failed renewals, refunds owed, plan limits and AI credit running low, failed mail and an unverified email. Each item links to the page that fixes it. The dashboard Overview shows it at the top; the AI connector's needs_attention tool reads it.
Audit log
- Records changes: creates, deletes, settings, keys, members, money events. Admins read it in Dashboard → Audit Log (filter, export to CSV) or
GET /api/orgs/:id/audit-logs. - High-volume data calls (queries, memory writes, vector upserts, object deletes, sends, checkout creation, agent runs) are counted in usage instead of written one row each.
GET /api/activityis the short recent feed on the Overview; non-admins do not see member, key, billing or account rows.
Reference
const usage = await hk.usage.get(); // counts, limits, aiCredits, metrics (30 days)AI connector (MCP)
The connector speaks the Model Context Protocol and signs you in with OAuth. You choose one workspace or all of them, and read-only or read-and-manage access. It then does what your role allows — nothing more — through the same API, with the same limits, audit rows and money checks. About 60 tools cover projects, deploys, repos, databases, storage, domains, payments analytics, AI and docs.
Connect
| Client | How |
|---|---|
| Claude (web and desktop) | Settings → Connectors → Add custom connector → paste https://harakumo.com/mcp → sign in and choose the workspace and access. |
| Claude Code | claude mcp add --transport http harakumo https://harakumo.com/mcp |
| ChatGPT | Turn on developer mode (Settings → Apps & Connectors → Advanced), create a connector with the URL https://harakumo.com/mcp and OAuth sign-in. Menu names change between ChatGPT releases. |
| Other clients (Cursor …) | { "mcpServers": { "harakumo": { "url": "https://harakumo.com/mcp" } } } |
| A headless agent | Send an API key instead: Authorization: Bearer hk_live_… (developer-role tools; a read-only key gets read tools). |
Access
- Read only: the assistant can look but not change anything. Read and manage: changes are allowed; tools that change or delete things are marked so the assistant asks you first.
- A connector acts with your own role, so an admin's connector also gets admin tools such as API keys, members and the audit log; an API key gets developer tools only. Money never leaves through a connector: withdrawals, refunds and payout setup are refused by design.
- A connection stays signed in and renews itself (the access token lasts 90 days and refreshes; unused for 180 days, it expires). Dashboard → Connectors lists every connection and cuts any of them off immediately.
Sending email from your own domain
Six tools let an assistant set up a sending domain end to end: it adds the domain, shows you the steps and the exact DNS records, checks them while you add them, and sets the project's From address once the domain is verified. See Mail → Send from your own domain.
add_mail_domain— start (or start again); returns the records, numbered steps and provider hints.check_mail_domain— check DNS now: each record found, missing or wrong, and "2 of 3 required records found".list_mail_domainsandget_mail_domain— status, records and every project's current From address.set_mail_from— a project's From address and Reply-To.remove_mail_domain— stop sending from a domain.- Also:
host_domain_dnshosts a bought domain's DNS here so every record is added for you, andsend_emailtakesfromandreplyTo.
New tools not showing?
Assistants remember a connector's tool list from when it was attached. After Harakumo adds tools, disconnect and reconnect the connector, then start a new conversation. The docs themselves are always live through get_docs.
Try asking
- "What needs my attention?" — one call to needs_attention, most urgent first.
- "Build a landing page for my bakery and put it online."
- "Why did the last deploy fail?"
- "How much of my plan and AI credit is left?"
- "Buy fruitshop.com and connect it to the store project."
- "Set up fruitshop.com as a sending domain on Harakumo and walk me through it."
Reference
// Nothing to install — the connector is the interface.