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.
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 userQuickstart
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.
| Code | Status | Meaning |
|---|---|---|
| VALIDATION_FAILED | 400 | A field failed validation; errors says which, and why. |
| BAD_REQUEST | 400 | Malformed request, or arguments that do not make sense together. |
| SOURCE_NOT_LINKED | 400 | You asked for a build, but the project has no Git repository linked yet. |
| INVALID_REPO_FULL_NAME | 400 | The repository is not a valid owner/repo slug, and none could be parsed out of the URL. |
| UNAUTHORIZED | 401 | No credentials, an unknown or revoked key — or a session-only route called with a key. |
| INVALID_CREDENTIALS | 401 | Wrong email or password, on a login route. |
| ACCESS_DENIED | 403 | You are in the workspace, but your role is too low for this particular action. |
| NOT_FOUND | 404 | No such resource — or it belongs to another workspace, which is answered identically on purpose. |
| METHOD_NOT_ALLOWED | 405 | The path exists, but not for this HTTP method. |
| CONFLICT | 409 | The action clashes with the current state — a name already taken, say. |
| SEAT_LIMIT_EXCEEDED | 409 | The workspace is out of seats on its plan. The action is blocked, never silently billed. |
| PLAN_LIMIT_EXCEEDED | 409 | A plan cap would be exceeded — firewall rules and IP rules are capped per plan. Blocked, never silently billed. |
| UNSUPPORTED_MEDIA_TYPE | 415 | A body arrived without Content-Type: application/json. |
| INTERNAL_ERROR | 500 | Something 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.
/api/public/workspaces/{workspaceId}/tokensCreate a key for this workspace, and return it in full — once.
Body
| name | string | A 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.
/api/public/workspaces/{workspaceId}/tokensList every key ever issued for the workspace, revoked ones included. The secret itself is never in the response.
/api/public/workspaces/{workspaceId}/tokens/{tokenId}204Revoke 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.
/api/public/projectsCreate a project in a workspace.
Body
| workspaceId | number | Workspace to create it in — necessarily the one your key belongs to. |
| name | string | Name of the project. |
| repoFullName | string | Repository as owner/repo, e.g. acme/storefront. |
| framework | string | Framework preset. Detected from the repository when omitted. |
| rootDir | string | Directory to build from, in a monorepo. Defaults to the repository root. |
| installCmd | string | Overrides the install command the framework would imply. |
| buildCmd | string | Overrides the build command the framework would imply. |
| nodeVersion | string | Node version to build with. |
* required
/api/public/projectsList the projects of one workspace.
Query
| workspaceId | number | Whose 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.
/api/public/projects/{id}Fetch a single project.
/api/public/projects/{id}204Delete a project.
This cascades: the project's deployments, domains, environment variables and firewall config go with it. There is no undo.
/api/public/projects/{id}/repoLink, re-link or unlink a project's Git repository.
Body
| provider | string | Source-control provider the repository lives on. |
| repoFullName | string | Repository as owner/repo. Rejected with INVALID_REPO_FULL_NAME when it is not a valid slug and none can be parsed out of repoUrl. |
| repoUrl | string | Clone URL, for a repository the linked GitHub App does not cover. |
| accessToken | string | Token used to clone a private repository. Stored encrypted and never returned — hasRepoToken tells you whether one is set. |
| clearToken | boolean | Send 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.
/api/public/projects/{projectId}/environments/{envKind}/env-varsList 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.
/api/public/projects/{projectId}/environments/{envKind}/env-vars/{key}204Create or overwrite one variable.
Body
| value | string | The value to store. Encrypted before it is written. |
* required
An unconditional overwrite. No If-Match, no version check: last write wins.
/api/public/projects/{projectId}/environments/{envKind}/env-vars/{key}204Remove 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.
/api/public/projects/{projectId}/buildsList a project's builds, newest first.
/api/public/projects/{projectId}/builds201Start a build. This is the deploy action.
Body
| branch | string | Branch to build. Defaults to main. |
Answers 201 Created. The project needs a repository linked, or this fails with 400 SOURCE_NOT_LINKED.
/api/public/builds/{buildId}Fetch one build — poll this for its status.
/api/public/builds/{buildId}/logsFetch a build's log as it stands right now.
/api/public/builds/{buildId}/logs/streamStream 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.
/api/public/projects/{projectId}/deploymentsList a project's deployments.
/api/public/projects/{projectId}/deployments/{deploymentId}/promotePoint the environment's alias at this deployment.
/api/public/projects/{projectId}/rollbackMove 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.
/api/public/projects/{projectId}/domainsRegister a hostname, and get back the DNS challenge to publish.
Body
| host | string | Hostname to attach, e.g. shop.acme.vn. |
* required
/api/public/projects/{projectId}/domainsList a project's domains and where each stands.
/api/public/projects/{projectId}/domains/{domainId}/verifyCheck 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.
/api/public/projects/{projectId}/observabilityRequests, error rate and latency over the last 24 hours.
/api/public/projects/{projectId}/analyticsVisitors 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.
/api/public/projects/{projectId}/wafHosts, 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.
/api/public/projects/{projectId}/waf/policies/{host}Set one host's policy: whether the firewall runs, and how strictly.
Body
| enabled | boolean | Whether the firewall runs for this host at all. |
| mode | OFF | DETECT | BLOCK | OFF ignores the rules; DETECT logs what it would have done; BLOCK actually enforces. |
| managedRulesets | string[] | 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.
/api/public/projects/{projectId}/waf/rulesList a project's custom rules.
/api/public/projects/{projectId}/waf/rulesCreate a custom rule.
Body
| name | string | Name of the rule, up to 120 characters. |
| priority | number | Evaluation order — lower runs first. |
| enabled | boolean | Defaults to on. |
| action | ALLOW | BLOCK | LOG | RATE_LIMIT | What to do on a match. RATE_LIMIT also needs a rateLimit budget; the other three must not carry one. |
| hosts | string[] | 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. |
| rateLimit | RateLimit | Budget for a RATE_LIMIT rule: how many requests per window, an optional burst, and what to count by. |
* required
/api/public/projects/{projectId}/waf/rules/{ruleId}Replace a custom rule.
/api/public/projects/{projectId}/waf/rules/{ruleId}204Delete a custom rule.
/api/public/projects/{projectId}/waf/ip-rulesList the IP allow/deny rules.
/api/public/projects/{projectId}/waf/ip-rulesAllow or deny an IP range.
Body
| kind | ALLOW | DENY | ALLOW lets the range straight through; DENY turns it away. |
| cidr | string | A single address or a CIDR range, e.g. 203.0.113.0/24. |
| hosts | string[] | Hosts the IP rule covers. Defaults to every host of the project. |
| note | string | A free-text reminder of why this range is on the list. |
| expiresAt | string | ISO-8601 instant at which the rule stops applying. Omit it for a permanent one. |
* required
/api/public/projects/{projectId}/waf/ip-rules/{ipRuleId}204Delete an IP rule.
/api/public/projects/{projectId}/waf/eventsThe event log: what the firewall blocked, limited, or merely noticed.
Query
| page | number | 0-based page index. Defaults to 0. |
| size | number | Rows per page. Defaults to 50. |
| host | string | Only events for this host. |
| action | string | Only events with this action. |
| from | string | Only events at or after this ISO-8601 instant, e.g. 2026-07-14T10:00:00Z. |
| to | string | Only 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.
/api/public/plansno key neededThe hosting plans, and what each includes.
/api/public/vps-plansno key neededThe VPS plans.
/api/public/domain-tldsno key neededThe 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.