Marina API
The surface everything else is built on. One base URL, a bearer token, and an error envelope worth branching on.
This is the whole of Marina over HTTP. The CLI is a client of it and so is MCP, which is why anything either of those can do, this can do.
Call it directly when neither fits: a language with no MCP client, a webhook handler, CI that only has curl.
curl https://marinahost.app/api/v1/vessels -H "Authorization: Bearer $MARINA_API_TOKEN" Auth
A bearer token, minted the same way as any other credential:
marina tokens create ci --preset deploy Every request carries it. There is no session, no cookie and no refresh, and what the token may do is fixed when it is minted. Tokens and scopes covers presets and how to narrow one.
The surface
GET /api/v1/account | Who this token belongs to and what it may do. |
GET /api/v1/wallet | Balance and payment code. |
GET /api/v1/resources | Everything the account runs, in one call. |
GET, POST /api/v1/vessels | List apps, create one. |
POST /api/v1/vessel-archives | Upload a .zip of a project, multipart. Answers with an id to pass as archiveId when you create the app. |
GET, DELETE /api/v1/vessels/{id} | One app. |
POST /api/v1/vessels/{id}/deploy | Ship it. |
GET /api/v1/vessels/{id}/builds | Deploy history. |
GET /api/v1/vessels/{id}/logs | Output, streamed. |
POST /api/v1/vessels/{id}/rollback | Put an earlier deploy back. |
PATCH /api/v1/vessels/{id}/size | Scale it. Upgrades only. |
POST /api/v1/vessels/{id}/run | One-off command inside the app. Needs vessel:exec. |
GET, PATCH, PUT /api/v1/vessels/{id}/env | Config vars. PATCH merges, PUT replaces, GET needs env:read. |
GET, POST /api/v1/sites, DELETE /api/v1/sites/{id} | Static sites. |
GET, POST /api/v1/databases, DELETE /api/v1/databases/{id} | Managed databases. |
GET /api/v1/databases/{id}/credentials | Connection details. Needs db:credentials. |
POST /api/v1/vessels/{id}/databases | Attach one. DELETE .../{database_id} detaches. |
GET /api/v1/domains/search | Is a name available, and what does it cost. |
GET, POST /api/v1/domains | Own and buy. |
GET /api/v1/domains/orders/{id} | A purchase in flight. |
GET, POST /api/v1/domains/custom | Point a domain you already own. |
POST /api/v1/domains/custom/{id}/verify | Re-check its records and publish the route. |
DELETE /api/v1/domains/custom/{id} | Stop pointing it here. |
GET /api/v1/dns/zones, GET /api/v1/dns/zones/{id} | Zones we host, and their records. |
POST /api/v1/dns/zones/{id}/records | Add a record. PATCH and DELETE on .../records/{rid}. |
GET, POST /api/v1/email | Email plans: list, and buy one for a domain. |
POST /api/v1/email/verify | Re-check a plan’s DNS and turn it on. |
GET, POST /api/v1/email/mailboxes | Addresses on a domain, and create one. |
POST /api/v1/email/mailboxes/delete | Remove an address and its mail. |
POST /api/v1/email/mailboxes/password | Change an address’s password. |
POST /api/v1/email/tier | Move a domain’s plan up or down. |
GET /api/v1/operations/{id} | A long-running operation’s state. |
GET /api/v1/invoices | What has been billed. |
Read endpoints need the matching :read scope, writes need :write, and anything that spends needs billing:spend as well.
Errors
Every failure has the same shape, so one branch handles all of them:
{
"error": {
"code": "INSUFFICIENT_FUNDS",
"message": "Your wallet is short GHS 187.42."
}
} The code is what to branch on; the message is what to show a person. The status is the ordinary HTTP one: 400 for a bad request, 401 unauthenticated, 403 not allowed, 404 missing, 409 conflicting, 402 for money, 429 rate limited.
Codes worth handling by name:
| Code | What to do |
|---|---|
INSUFFICIENT_FUNDS | Nothing was created or charged. Tell the person how to top up, then retry. |
SPEND_LIMIT_EXCEEDED | This token’s lifetime cap is reached. A new credential is the only way past it. |
FORBIDDEN | The token lacks the scope. Do not retry; it will never succeed. |
CONFLICT | Usually the name is taken, or the resource is in a state that forbids this. |
When there is no money
A 402 carries the remedy, not just the refusal:
{
"error": {
"code": "INSUFFICIENT_FUNDS",
"message": "Your wallet is short GHS 187.42.",
"details": {
"required": "190.00",
"balance": "2.58",
"shortfall": "187.42",
"currency": "GHS",
"paymentCode": "<your code>",
"momoNumber": "0558863139",
"accountName": "ISAAC KWEKU BADU SMITH"
}
}
} shortfall is precomputed so you never do decimal arithmetic on two strings, and every amount is a string at exactly two places. Do not parse money into a float. Nothing was created and nothing was charged, so a retry after the balance arrives is simply the same request again.
Retrying a create safely
A create that times out may still have happened. Send an Idempotency-Key and a repeat returns the original result rather than buying a second one:
curl -X POST https://marinahost.app/api/v1/databases -H "Authorization: Bearer $MARINA_API_TOKEN" -H "Idempotency-Key: 9f2c1e7a-order-42" -H "Content-Type: application/json" -d '{"name":"store","engine":"postgres","storageGb":1}' Pick the key from something stable in your own system, so the retry after a crash uses the same one. This is the only protection that closes the window fully: a name check cannot see a create that has paid and is still provisioning.
Buying a domain finishes later
A domain purchase reserves the name, holds the price, and returns an order:
curl https://marinahost.app/api/v1/domains/orders/{id} -H "Authorization: Bearer $MARINA_API_TOKEN" Poll that until it settles. Everything else on this API completes while you wait.
Things this API cannot do
Deliberately, and there is no flag for any of them: mint another token, invite a member, change the account, or add storage to an app. Account-level actions need a signed-in session, so a leaked credential cannot mint a replacement for itself or bring in an accomplice.
