mmoall
DeployVPSDomainsPricingDocs
Log inStart for free

api

API reference

Almost everything the dashboard does, your code can do too: create a project, ship a deploy, stream a build log, lock a domain down. Plain REST, JSON in and out. This page documents the endpoints that exist today — and nothing else.

Base URL·https://api.mmoall.com

Contents

  1. 01Overview
  2. 02Authentication
  3. 03Quickstart
  4. 04Requests and responses
  5. 05Errors
  6. 06Limits
  7. 07API keys
  8. 08Projects
  9. 09Environment variables
  10. 10Builds and logs
  11. 11Deployments
  12. 12Custom domains
  13. 13Metrics
  14. 14Firewall (WAF)
  15. 15Catalog

Overview

The API is REST over HTTP, with JSON in both directions. Every path below hangs off one base URL, and every authenticated call carries one header.

It is the same API the dashboard itself runs on: the customer surface is everything under /api/public, and the dashboard gets at it through no faster private door. Two routes are the exception — listing your workspaces, and adding a member — because they identify a person rather than a workspace, and a key is not a person. Authentication below says exactly what that means for you.

https://api.mmoall.com/api/public
  • Create projects, link a Git repository, and delete a project along with everything it deployed.
  • Set and remove environment variables per environment. They are encrypted at rest and never read back.
  • Trigger a deploy, poll the build, and read or live-stream its logs.
  • Attach a custom domain and verify it over DNS.
  • Drive the firewall: policies, rules, IP allow/deny lists, and the event log.
  • Read traffic, error rate, latency, visitors and page views.

Routes exist that this page deliberately leaves out: the admin console, the build-agent fleet, and the Git webhooks a provider calls. They are not customer surface, they carry no compatibility promise, and an API key cannot reach them.

Authentication

Every route below, except the catalog, needs an API key sent as a bearer token:

Authorization: Bearer mmt_Yk9uZTMyLWJ5dGVzLW9mLXJhbmRvbW5lc3M

Keys are created in the dashboard, on the API Keys page.

  • A key is mmt_ followed by 32 random bytes in base64url.
  • The full key is shown exactly once, at creation. Copy it then — we cannot show it to you a second time.
  • We store only a SHA-256 hash of it, so a breach of our database does not hand anyone your keys.
  • Revoking takes effect on the very next request: every call re-reads the key from the database, and there is no cache to wait out.

One key, one workspace

A key is bound to the single workspace it was created in, and authorises that workspace's routes only.

Ask for a project, build or domain in a different workspace and the answer is 404 Not Found — never 403. That is deliberate: the platform will not confirm that a resource it refuses to show you even exists. So if an id is definitely right and you still get a 404, the question to ask is not “is the URL wrong” but “does this key belong to the workspace that owns it”.

Two routes resolve a user, which a key is not, and therefore always answer 401 when called with a bearer token: GET /api/public/workspaces and POST /api/public/workspaces/{workspaceId}/members. They are session-only, by design.

Since a key cannot list your workspaces, you have to know your workspaceId up front. The dashboard reads it with your login session, so the quickest way to see it is to call that same route from a browser you are signed in to Mmoall in — a bearer token will not work there. Otherwise: any project you fetch carries its workspaceId in the response.

GET https://api.mmoall.com/api/public/workspaces
  ← login session   200  [{ "id": 1, "name": "acme", … }]
  ← bearer token    401  UNAUTHORIZED   // a key is not a user

Quickstart

From a fresh key to a running deploy, in four calls. Export the key as MMOALL_TOKEN first. The ids below are examples — use your own.

1. Find a project. Doubles as proof that the key works. workspaceId is required.

curl -s "https://api.mmoall.com/api/public/projects?workspaceId=1" \
  -H "Authorization: Bearer $MMOALL_TOKEN"

2. Set a secret. Encrypted before it is written, overwriting whatever was there. No route ever reads it back.

curl -s -X PUT \
  "https://api.mmoall.com/api/public/projects/42/environments/production/env-vars/DATABASE_URL" \
  -H "Authorization: Bearer $MMOALL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"value":"postgres://…"}'

3. Deploy. Answers 201 with the new build — including the id you need next.

curl -s -X POST "https://api.mmoall.com/api/public/projects/42/builds" \
  -H "Authorization: Bearer $MMOALL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"branch":"main"}'

4. Watch it build. Server-Sent Events, live until the build finishes.

curl -N "https://api.mmoall.com/api/public/builds/1001/logs/stream" \
  -H "Authorization: Bearer $MMOALL_TOKEN"

Requests and responses

Every successful JSON response is wrapped in a data field. Unwrap it once, and what is inside is the shape the endpoint documents.

{
  "data": {
    "id": 42,
    "name": "storefront"
  }
}
  • A 2xx with a body is always { "data": … } — whether data holds an object or an array.
  • A 204 has no body at all. Deletes and environment-variable writes answer this way.
  • Send Content-Type: application/json on POST and PUT. Anything else answers 415.
  • Send Accept-Language: vi (or en) and error messages come back in that language. Field names and codes never change.
  • Ids are integers. Timestamps are ISO-8601, in UTC.

Exactly one route is paginated: the firewall event log. It answers with a page object inside the usual envelope, where number is the 0-based page index. Every other list route hands back the whole array.

{
  "data": {
    "content": [ /* … */ ],
    "totalElements": 137,
    "totalPages": 3,
    "number": 0,
    "size": 50,
    "first": true,
    "last": false
  }
}

Errors

Errors are RFC 7807 problem documents, not the data envelope. The HTTP status gives the class of failure; the code field names the exact one.

{
  "type": "about:blank",
  "title": "Validation failed",
  "status": 400,
  "detail": "One or more fields are invalid.",
  "code": "VALIDATION_FAILED",
  "errors": {
    "name": "must not be blank"
  }
}

Branch on code, never on title or detail — those are prose, they are translated, and they get reworded. The errors object appears only on VALIDATION_FAILED, mapping each rejected field to its reason.

CodeStatusMeaning
VALIDATION_FAILED400A field failed validation; errors says which, and why.
BAD_REQUEST400Malformed request, or arguments that do not make sense together.
SOURCE_NOT_LINKED400You asked for a build, but the project has no Git repository linked yet.
INVALID_REPO_FULL_NAME400The repository is not a valid owner/repo slug, and none could be parsed out of the URL.
UNAUTHORIZED401No credentials, an unknown or revoked key — or a session-only route called with a key.
INVALID_CREDENTIALS401Wrong email or password, on a login route.
ACCESS_DENIED403You are in the workspace, but your role is too low for this particular action.
NOT_FOUND404No such resource — or it belongs to another workspace, which is answered identically on purpose.
METHOD_NOT_ALLOWED405The path exists, but not for this HTTP method.
CONFLICT409The action clashes with the current state — a name already taken, say.
SEAT_LIMIT_EXCEEDED409The workspace is out of seats on its plan. The action is blocked, never silently billed.
PLAN_LIMIT_EXCEEDED409A plan cap would be exceeded — firewall rules and IP rules are capped per plan. Blocked, never silently billed.
UNSUPPORTED_MEDIA_TYPE415A body arrived without Content-Type: application/json.
INTERNAL_ERROR500Something broke on our side. Safe to retry.

404 NOT_FOUND is also the answer for a resource that exists but is not yours — see “One key, one workspace” above. It is not a bug, and retrying will never turn it into a 403.

Limits

There is no rate limit on this API today. We do not throttle, meter or reject calls for arriving too fast — no such filter is deployed. We would rather write that down than publish a limit we do not enforce.

That is a fact about today, not a promise for ever. A rate limit is an ordinary thing for a platform to add, and if we add one it will answer 429 and be documented here before it is switched on. So do not build a client that leans on its absence: back off when a call fails, and stream a build log rather than polling it in a tight loop.

Plan caps are a different thing, and they are real: seats, firewall rules and IP rules are limited per plan, and going over answers 409 with SEAT_LIMIT_EXCEEDED or PLAN_LIMIT_EXCEEDED. The rate-limit rules you can configure in the firewall are a third thing again — those limit your visitors, not your API calls.

API keys

The keys of one workspace. You can manage them in the dashboard too — these routes exist so a key can be rotated from CI without a human.

POST/api/public/workspaces/{workspaceId}/tokens

Create a key for this workspace, and return it in full — once.

Body

name*stringA label to recognise it by later — the CI job that uses it, say.

* required

The one and only response that carries the token field. Store it now; we keep just a hash and genuinely cannot show it again.

GET/api/public/workspaces/{workspaceId}/tokens

List every key ever issued for the workspace, revoked ones included. The secret itself is never in the response.

DELETE/api/public/workspaces/{workspaceId}/tokens/{tokenId}204

Revoke a key. A soft delete: it stays in the list, marked revoked.

Effective at once: the next request made with that key fails. There is no cache to expire.

Response

// POST → CreatedApiTokenView — the only response that ever carries `token`
{
  "id": 7,
  "name": "ci-deploy",
  "prefix": "mmt_Yk9uZTMy",
  "token": "mmt_Yk9uZTMyLWJ5dGVzLW9mLXJhbmRvbW5lc3M",
  "createdAt": "2026-07-14T09:12:00Z"
}

// GET → ApiTokenView[]
{
  "id": 7,
  "name": "ci-deploy",
  "prefix": "mmt_Yk9uZTMy",
  "createdAt": "2026-07-14T09:12:00Z",
  "lastUsedAt": "2026-07-14T11:40:03Z",
  "revokedAt": null
}

Projects

A project is a repository, the settings used to build it, and the environments it deploys to.

POST/api/public/projects

Create a project in a workspace.

Body

workspaceId*numberWorkspace to create it in — necessarily the one your key belongs to.
name*stringName of the project.
repoFullNamestringRepository as owner/repo, e.g. acme/storefront.
frameworkstringFramework preset. Detected from the repository when omitted.
rootDirstringDirectory to build from, in a monorepo. Defaults to the repository root.
installCmdstringOverrides the install command the framework would imply.
buildCmdstringOverrides the build command the framework would imply.
nodeVersionstringNode version to build with.

* required

GET/api/public/projects

List the projects of one workspace.

Query

workspaceId*numberWhose projects to list.

* required

workspaceId is required; without it the request is rejected with 400 BAD_REQUEST. There is no “list all my projects” — a key only ever sees one workspace anyway.

GET/api/public/projects/{id}

Fetch a single project.

DELETE/api/public/projects/{id}204

Delete a project.

This cascades: the project's deployments, domains, environment variables and firewall config go with it. There is no undo.

PUT/api/public/projects/{id}/repo

Link, re-link or unlink a project's Git repository.

Body

providerstringSource-control provider the repository lives on.
repoFullNamestringRepository as owner/repo. Rejected with INVALID_REPO_FULL_NAME when it is not a valid slug and none can be parsed out of repoUrl.
repoUrlstringClone URL, for a repository the linked GitHub App does not cover.
accessTokenstringToken used to clone a private repository. Stored encrypted and never returned — hasRepoToken tells you whether one is set.
clearTokenbooleanSend true to delete the stored token.

Response

// ProjectView
{
  "id": 42,
  "workspaceId": 1,
  "name": "storefront",
  "repoFullName": "acme/storefront",
  "framework": "nextjs",
  "githubInstallationId": 90210,
  "analyticsEnabled": true,
  "environments": ["PRODUCTION", "PREVIEW"],
  "scmProvider": "github",
  "repoCloneUrl": "https://github.com/acme/storefront.git",
  "hasRepoToken": false
}

Environment variables

Configuration and secrets, per project and per environment. Encrypted at rest, and write-only: no route on this platform will read a value back to you.

envKind in the path is PRODUCTION or PREVIEW, matched case-insensitively — production works just as well as PRODUCTION.

GET/api/public/projects/{projectId}/environments/{envKind}/env-vars

List the variable keys set for one environment.

Keys and their last-updated time — never values. That is the whole point: a leaked API key must not become a leaked database password.

PUT/api/public/projects/{projectId}/environments/{envKind}/env-vars/{key}204

Create or overwrite one variable.

Body

value*stringThe value to store. Encrypted before it is written.

* required

An unconditional overwrite. No If-Match, no version check: last write wins.

DELETE/api/public/projects/{projectId}/environments/{envKind}/env-vars/{key}204

Remove one variable.

Response

// GET → EnvVarKeyView[] — keys only. A value is never returned by any route.
{
  "key": "DATABASE_URL",
  "updatedAt": "2026-07-14T09:12:00Z"
}

Builds and logs

A build turns a commit into a deployable artifact. Starting one is exactly what the “deploy” button does.

GET/api/public/projects/{projectId}/builds

List a project's builds, newest first.

POST/api/public/projects/{projectId}/builds201

Start a build. This is the deploy action.

Body

branchstringBranch to build. Defaults to main.

Answers 201 Created. The project needs a repository linked, or this fails with 400 SOURCE_NOT_LINKED.

GET/api/public/builds/{buildId}

Fetch one build — poll this for its status.

GET/api/public/builds/{buildId}/logs

Fetch a build's log as it stands right now.

GET/api/public/builds/{buildId}/logs/stream

Stream the log live, as Server-Sent Events.

This one is text/event-stream, not JSON — the single response on the platform that is not wrapped in the data envelope. Read it with an SSE client (or curl -N), not a JSON parser.

Response

// BuildView
{
  "id": 1001,
  "projectId": 42,
  "commitSha": "9f2c1ab",
  "branch": "main",
  "envKind": "PRODUCTION",
  "status": "READY",
  "artifactId": 55,
  "createdAt": "2026-07-14T09:12:00Z"
}

// GET /logs → BuildLogsView. droppedLines > 0 means the buffer overflowed and
// the log you are reading is not complete.
{
  "lines": [
    { "id": 1, "level": "INFO", "message": "▲ Next.js 16.0.0", "emittedAt": "2026-07-14T09:12:03Z" },
    { "id": 2, "level": "INFO", "message": "Creating an optimized production build …", "emittedAt": "2026-07-14T09:12:04Z" }
  ],
  "droppedLines": 0
}

Deployments

A deployment is an artifact that is live somewhere. Promote and rollback move an alias between deployments; neither rebuilds anything, so both are near-instant.

GET/api/public/projects/{projectId}/deployments

List a project's deployments.

POST/api/public/projects/{projectId}/deployments/{deploymentId}/promote

Point the environment's alias at this deployment.

POST/api/public/projects/{projectId}/rollback

Move the alias back to the previous deployment.

Response

// DeploymentView
{
  "id": 300,
  "projectId": 42,
  "buildId": 1001,
  "artifactId": 55,
  "envKind": "PRODUCTION",
  "status": "READY",
  "immutableUrl": "https://storefront-9f2c1ab.mmoall.app",
  "createdAt": "2026-07-14T09:14:00Z"
}

// promote and rollback → AliasView: where the alias points now.
{
  "host": "storefront.mmoall.app",
  "projectId": 42,
  "envKind": "PRODUCTION",
  "deploymentId": 300,
  "contentHash": "sha256:…",
  "storageKey": "artifacts/55.tar.zst",
  "updatedAt": "2026-07-14T09:15:00Z"
}

Custom domains

Put your own hostname in front of a project. Ownership is proven with a DNS record before we will serve traffic for it.

POST/api/public/projects/{projectId}/domains

Register a hostname, and get back the DNS challenge to publish.

Body

host*stringHostname to attach, e.g. shop.acme.vn.

* required

GET/api/public/projects/{projectId}/domains

List a project's domains and where each stands.

POST/api/public/projects/{projectId}/domains/{domainId}/verify

Check the DNS record and, if it matches, verify the domain.

Publish the challengeRecordName / challengeRecordValue record from the registration response first. DNS takes time to propagate — if this fails, wait and call it again.

Response

// CustomDomainView — challengeRecord* is the DNS record you must publish before verifying.
{
  "id": 9,
  "projectId": 42,
  "host": "shop.acme.vn",
  "envKind": "PRODUCTION",
  "status": "PENDING",
  "challengeRecordName": "_mmoall-challenge.shop.acme.vn",
  "challengeRecordValue": "mmoall-verify=8f3c…",
  "verifiedAt": null,
  "createdAt": "2026-07-14T09:12:00Z"
}

Metrics

Two different questions, answered by two deliberately separate routes: how many requests hit the edge, and how many people came. A request is not a visit, and these numbers are never mixed together.

GET/api/public/projects/{projectId}/observability

Requests, error rate and latency over the last 24 hours.

GET/api/public/projects/{projectId}/analytics

Visitors and page views over the last 7 days, plus the top pages.

Response

// ObservabilityView — requests, from the edge.
{
  "requests24h": 128400,
  "errorRate": 0.004,
  "avgLatencyMs": 63,
  "series": [ /* … */ ]
}

// AnalyticsView — people, from the beacon. A request and a page view are
// different questions; these two responses never mix them.
{
  "visitors7d": 4120,
  "pageViews7d": 9880,
  "visitorsChangePct": 12.5,
  "pageViewsChangePct": -3.1,
  "topPages": [ /* … */ ]
}

Firewall (WAF)

Rules the edge enforces on your visitors, before a request ever reaches your app. Changes reach the edge within seconds — nothing has to be redeployed.

Custom rules, IP rules and managed rulesets are capped per plan. Going over answers 409 PLAN_LIMIT_EXCEEDED — and the overview route tells you the caps, and your usage against them, before you try.

GET/api/public/projects/{projectId}/waf

Hosts, policies, rules, IP rules and your plan's caps — in one call.

Everything the firewall screen needs in a single round trip — including your plan's caps and how much of them you have already used.

PUT/api/public/projects/{projectId}/waf/policies/{host}

Set one host's policy: whether the firewall runs, and how strictly.

Body

enabled*booleanWhether the firewall runs for this host at all.
mode*OFF | DETECT | BLOCKOFF ignores the rules; DETECT logs what it would have done; BLOCK actually enforces.
managedRulesetsstring[]Managed rulesets to switch on — one or more of sqli, xss, traversal, rce, scanner. Any other id is rejected. How many you may have is capped per plan.

* required

The default policy — the one covering every host that has none of its own — has the literal host *, which must be escaped as %2A in the URL.

GET/api/public/projects/{projectId}/waf/rules

List a project's custom rules.

POST/api/public/projects/{projectId}/waf/rules

Create a custom rule.

Body

name*stringName of the rule, up to 120 characters.
priority*numberEvaluation order — lower runs first.
enabledbooleanDefaults to on.
action*ALLOW | BLOCK | LOG | RATE_LIMITWhat to do on a match. RATE_LIMIT also needs a rateLimit budget; the other three must not carry one.
hostsstring[]Hosts the rule covers. Defaults to every host of the project.
conditions*Condition[]What has to match — a field, an operator, and a value. At least one is required.
rateLimitRateLimitBudget for a RATE_LIMIT rule: how many requests per window, an optional burst, and what to count by.

* required

PUT/api/public/projects/{projectId}/waf/rules/{ruleId}

Replace a custom rule.

DELETE/api/public/projects/{projectId}/waf/rules/{ruleId}204

Delete a custom rule.

GET/api/public/projects/{projectId}/waf/ip-rules

List the IP allow/deny rules.

POST/api/public/projects/{projectId}/waf/ip-rules

Allow or deny an IP range.

Body

kind*ALLOW | DENYALLOW lets the range straight through; DENY turns it away.
cidr*stringA single address or a CIDR range, e.g. 203.0.113.0/24.
hostsstring[]Hosts the IP rule covers. Defaults to every host of the project.
notestringA free-text reminder of why this range is on the list.
expiresAtstringISO-8601 instant at which the rule stops applying. Omit it for a permanent one.

* required

DELETE/api/public/projects/{projectId}/waf/ip-rules/{ipRuleId}204

Delete an IP rule.

GET/api/public/projects/{projectId}/waf/events

The event log: what the firewall blocked, limited, or merely noticed.

Query

pagenumber0-based page index. Defaults to 0.
sizenumberRows per page. Defaults to 50.
hoststringOnly events for this host.
actionstringOnly events with this action.
fromstringOnly events at or after this ISO-8601 instant, e.g. 2026-07-14T10:00:00Z.
tostringOnly events before this ISO-8601 instant.

The only paginated route on the API. The page shape is under “Requests and responses” above.

Response

// GET /waf → WafOverviewView: hosts, policies, rules, IP rules and your plan's
// caps, in one round trip.
{
  "hosts": ["*", "shop.acme.vn"],
  "policies": [
    { "id": 1, "projectId": 42, "host": "*", "enabled": true, "mode": "BLOCK",
      "managedRulesets": ["sqli", "xss"], "updatedAt": "2026-07-14T09:12:00Z" }
  ],
  "rules": [
    { "id": 5, "projectId": 42, "name": "block-admin", "priority": 100, "enabled": true,
      "action": "BLOCK", "hosts": ["*"],
      "conditions": [{ "field": "path", "op": "starts_with", "value": "/wp-admin" }],
      "updatedAt": "2026-07-14T09:12:00Z" }
  ],
  "ipRules": [
    { "id": 3, "projectId": 42, "kind": "DENY", "cidr": "203.0.113.0/24", "hosts": ["*"],
      "note": "scraper", "expiresAt": null, "createdAt": "2026-07-14T09:12:00Z" }
  ],
  "limits": {
    "wafEnabled": true, "maxCustomRules": 20, "maxIpRules": 50,
    "maxManagedRulesets": 3, "usedCustomRules": 1, "usedIpRules": 1
  }
}

// A RATE_LIMIT rule carries a rateLimit budget; no other action does.
"rateLimit": { "requests": 100, "windowSeconds": 60, "burst": 20, "key": "ip" }

Catalog

The public price lists behind the marketing pages. No key needed: these are the only routes on the API you can call without one.

GET/api/public/plansno key needed

The hosting plans, and what each includes.

GET/api/public/vps-plansno key needed

The VPS plans.

GET/api/public/domain-tldsno key needed

The domain extensions we sell, and their prices.

This page documents what the platform serves today. If an endpoint is not here, it does not exist yet: we would rather leave an obvious gap than promise you a route that answers 404.

mmoall

Infrastructure for products built in Vietnam.

Services

  • Deploy
  • VPS
  • Domains
  • Pricing

Account

  • Log in
  • Start for free
  • Overview

Contact

  • sales@mmoall.vn

© 2026 Mmoall. All rights reserved.

Prices on this site exclude VAT.

DocsPrivacy policyData policy