Every API here is available over the APIs.io API and to AI agents over MCP.
# DevMargin HTTP API — OpenAPI 3.1
#
# Two surfaces with two different credentials, which is the first thing to get
# right when reading this:
#
# /api/push — a PUSH TOKEN (dmp_…) your product holds. Write-only: it can
# report figures and cannot read your portfolio back.
# /api/v1/* — a SUPABASE ACCESS TOKEN (a user session JWT), used by the iOS
# app. Read, plus two writes a person triggers.
#
# Not in here on purpose:
# /api/mcp — MCP protocol, not REST; register the endpoint
# in your agent instead (Settings → Agent access)
# /api/webhooks/* — inbound, signature-verified, called by Stripe
# and Resend rather than by you
# /api/cron/* — scheduled, CRON_SECRET-gated
#
# The narrative version of the push API, with worked examples and an agent
# prompt, is docs/push-api.md and https://devmargin.app/docs/push-api.
#
# ONE RULE ABOVE THE SCHEMAS: omit what you do not know. Every nullable figure
# here means "not reported", never zero. Sending 0 for an unknown, or an
# explicit null, is refused — a fabricated zero silently corrupts net margin,
# which is the number the product exists to get right.
openapi: 3.1.0
info:
title: DevMargin API
version: "1.0.0"
summary: Revenue and net margin across billing rails, for indie portfolios.
description: |
DevMargin pulls revenue from billing rails, subtracts each project's running
costs, and reports net margin. This API covers the two things it cannot do
by pulling: letting a product report what no rail knows, and letting the iOS
companion read the result.
contact:
name: DevMargin
url: https://devmargin.app
email: hello@devmargin.app
servers:
- url: https://devmargin.app
description: Production
tags:
- name: push
description: |
What your own product reports about itself. Write-only.
- name: read
description: |
The signed-in read API, used by the iOS app. Figures are canonical USD
(`*Usd`) with a display conversion alongside.
security: []
paths:
/api/push:
post:
tags: [push]
operationId: push
summary: Report metrics, an earned month, or a running cost
description: |
One request carries exactly one of three shapes. Sending more than one
at a time is refused rather than guessed at.
* **metrics** — latest state: what the product is earning and how many
users it has, right now.
* **earned** — a dated fact: what a given month earned. This is what
gives a push channel an Earned history and a lifetime total.
* **cost** — a running bill, upserted by label.
Rate limit: 60 requests per hour per token. Once a day is plenty; the
snapshot that reads pushed metrics runs daily at 05:00 UTC.
security:
- pushToken: []
requestBody:
required: true
content:
application/json:
schema:
oneOf:
- $ref: "#/components/schemas/PushMetricsBody"
- $ref: "#/components/schemas/PushEarnedBody"
- $ref: "#/components/schemas/PushCostBody"
examples:
users:
summary: A user count on a rail-backed channel
value:
channel: 3f1c2a5e-0f3d-4d3a-9a2b-4b1f6f0a2c11
metrics: { users: 1234 }
earnedMonth:
summary: What July earned, after the marketplace took its cut
value:
channel: 3f1c2a5e-0f3d-4d3a-9a2b-4b1f6f0a2c11
earned:
month: "2026-07"
amount: 48200
currency: CZK
basis: proceeds
cost:
summary: A monthly bill
value:
project: 7d0e1b44-2f8a-4a11-8f6a-99c0d2b7ee31
cost: { label: OpenAI, monthly: 42.10, currency: USD }
responses:
"200":
description: Stored.
content:
application/json:
schema:
oneOf:
- $ref: "#/components/schemas/PushMetricsResult"
- $ref: "#/components/schemas/PushEarnedResult"
- $ref: "#/components/schemas/PushCostResult"
"400":
$ref: "#/components/responses/PushError"
"401":
description: Missing or invalid token.
content:
application/json:
schema: { $ref: "#/components/schemas/PushErrorBody" }
"403":
description: |
Wrong kind of token. A read/MCP token (`dmt_…`) can never write.
content:
application/json:
schema: { $ref: "#/components/schemas/PushErrorBody" }
"404":
description: That channel or project is not in this account.
content:
application/json:
schema: { $ref: "#/components/schemas/PushErrorBody" }
"422":
description: |
Money pushed to a rail-backed channel. A channel's money comes from
exactly one source, so reporting revenue for a channel that already
draws on Stripe, RevenueCat, the App Store or Google Play would
double-count what the rail reports. Only `users` is accepted there.
content:
application/json:
schema: { $ref: "#/components/schemas/PushErrorBody" }
"429":
description: Over 60 pushes in the last hour for this token.
content:
application/json:
schema: { $ref: "#/components/schemas/PushErrorBody" }
/api/v1/portfolio:
get:
tags: [read]
operationId: getPortfolio
summary: Portfolio totals, per-project rows and the net trend
description: |
The iOS home screen in one call. Net margin is computed server-side and
must not be recomputed by a client — there is one implementation of that
maths and it is not in the phone.
Projects come back worst-margin first, because "which one is bleeding"
is the question the product answers. A project whose net is unknown
sorts last rather than as zero: it is unmeasured, not healthy.
security:
- sessionToken: []
parameters:
- $ref: "#/components/parameters/Days"
responses:
"200":
description: |
The latest snapshot rolled up. When no snapshot exists yet the body
is `{ "error": "…" }` with a 200, since an account that has not
pulled is not an error condition.
content:
application/json:
schema: { $ref: "#/components/schemas/Portfolio" }
"401":
$ref: "#/components/responses/Unauthorized"
/api/v1/trend:
get:
tags: [read]
operationId: getTrend
summary: Portfolio net margin over time, one point per pull
security:
- sessionToken: []
parameters:
- $ref: "#/components/parameters/Days"
responses:
"200":
description: |
One point per snapshot, oldest first. A free plan's window is
shorter than the days requested; the payload says which window it
actually covers rather than letting the caller assume.
content:
application/json:
schema:
type: object
additionalProperties: true
"401":
$ref: "#/components/responses/Unauthorized"
/api/v1/projects/{name}:
get:
tags: [read]
operationId: getProject
summary: One project, its channels and its costs
security:
- sessionToken: []
parameters:
- name: name
in: path
required: true
description: The project's name, URL-encoded.
schema: { type: string }
- $ref: "#/components/parameters/Days"
responses:
"200":
description: The project rollup.
content:
application/json:
schema:
type: object
additionalProperties: true
"401":
$ref: "#/components/responses/Unauthorized"
"404":
description: No project of that name in this account.
/api/v1/costs:
post:
tags: [read]
operationId: createCost
summary: Add a cost
description: |
The only write the iOS app makes. A one-off carries the date it
happened and counts in that month alone — it has no monthly rate, so it
never reaches a `/mo` figure.
security:
- sessionToken: []
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/NewCost" }
responses:
"201":
description: Created.
content:
application/json:
schema:
type: object
properties:
cost:
type: object
additionalProperties: true
"401":
$ref: "#/components/responses/Unauthorized"
"422":
description: |
A field is missing or contradictory — an unknown project, a `once`
cost with no date, a date on a rate, or a shared one-off.
content:
application/json:
schema: { $ref: "#/components/schemas/V1ErrorBody" }
/api/v1/refresh:
post:
tags: [read]
operationId: refresh
summary: Pull every connected rail now
security:
- sessionToken: []
responses:
"200":
description: The pull ran; the body carries its notes and record count.
content:
application/json:
schema:
type: object
additionalProperties: true
"401":
$ref: "#/components/responses/Unauthorized"
"402":
description: |
Refreshing on demand is a Pro feature. The daily pull still runs at
05:00 UTC, so a free account's figures are at most a day old.
content:
application/json:
schema: { $ref: "#/components/schemas/V1ErrorBody" }
"500":
description: The pull failed; the message says which rail and why.
content:
application/json:
schema: { $ref: "#/components/schemas/V1ErrorBody" }
components:
securitySchemes:
pushToken:
type: http
scheme: bearer
description: |
A push token, `dmp_…`, created in Settings → Push API. It is a WRITE
credential: keep it in a server-side environment variable, never in a
mobile binary or client-side JavaScript. It cannot read your portfolio,
which is deliberate — a leaked push token cannot exfiltrate anything.
sessionToken:
type: http
scheme: bearer
bearerFormat: JWT
description: |
A Supabase access token for the signed-in user, as obtained by the iOS
app at sign-in. Not an API key and not issued in Settings.
parameters:
Days:
name: days
in: query
required: false
description: |
Window in days. Clamped rather than rejected — an odd value still
returns a chart. A free plan's history window may be shorter than
requested, and the response says what it actually covers.
schema:
type: integer
minimum: 1
maximum: 365
default: 30
responses:
Unauthorized:
description: Missing, invalid or expired access token.
content:
application/json:
schema: { $ref: "#/components/schemas/V1ErrorBody" }
PushError:
description: |
A field is missing, malformed, or explicitly null. Null is refused: a
value you do not know is omitted, not sent as nothing.
content:
application/json:
schema: { $ref: "#/components/schemas/PushErrorBody" }
schemas:
PushErrorBody:
type: object
properties:
error:
type: string
description: A sentence saying what to change, not a code.
required: [error]
V1ErrorBody:
type: object
properties:
error: { type: string }
message: { type: string }
required: [error]
PushMetricsBody:
type: object
title: metrics — latest state
description: |
Every field is optional; send only what you actually know. A missing
field shows as “—” and stays honestly unknown.
On a channel backed by a billing rail, `users` is the ONLY field
accepted — everything else is the rail's to report.
properties:
channel:
type: string
format: uuid
description: Channel id, from Projects → the project → its channel.
metrics:
type: object
properties:
users:
type: integer
minimum: 0
description: The only field a rail-backed channel accepts.
activeSubs: { type: integer, minimum: 0 }
newSubs: { type: integer, minimum: 0 }
churnedSubs: { type: integer, minimum: 0 }
mrr:
type: number
minimum: 0
description: Push-rail channels only.
arr:
type: number
minimum: 0
description: Push-rail channels only.
oneOffRevenue:
type: number
minimum: 0
description: |
Push-rail channels only. Do not also report this money through
`earned`, or the same takings are described twice.
currency:
type: string
pattern: "^[A-Z]{3}$"
description: ISO-4217. Converted to USD at snapshot FX.
additionalProperties: false
required: [channel, metrics]
additionalProperties: false
PushEarnedBody:
type: object
title: earned — a month that already happened
description: |
Push-rail channels only. Upserted by month, so a corrected figure
replaces the earlier one instead of adding to it.
The USD value is filled in at the next pull, from that run's exchange
rates — this path has no FX source of its own, and the product keeps
exactly one per run so every figure traces to the rates it used.
properties:
channel: { type: string, format: uuid }
earned:
type: object
properties:
month:
type: string
pattern: "^\\d{4}-\\d{2}(-01)?$"
description: |
`YYYY-MM`. The current month is accepted and recorded as
partial; a future month is refused.
examples: ["2026-07"]
amount:
type: number
minimum: 0
description: In `currency`.
currency:
type: string
pattern: "^[A-Z]{3}$"
basis:
type: string
enum: [gross, proceeds]
description: |
REQUIRED, with no default on purpose. `gross` is before any
platform fee; `proceeds` is what actually reached you. The same
number means different things depending on which, and a
portfolio total that mixed them would be wrong invisibly.
required: [month, amount, currency, basis]
additionalProperties: false
required: [channel, earned]
additionalProperties: false
PushCostBody:
type: object
title: cost — a running bill
description: |
Upserted by `label`, so re-pushing the same bill daily updates one row
instead of piling up duplicates. Costs reach net margin immediately;
no pull is needed.
properties:
project: { type: string, format: uuid }
cost:
type: object
properties:
label:
type: string
minLength: 1
description: The upsert key — keep it stable across pushes.
monthly:
type: number
minimum: 0
description: A monthly amount. Divide yearly bills by twelve.
currency:
type: string
pattern: "^[A-Z]{3}$"
required: [label, monthly, currency]
additionalProperties: false
required: [project, cost]
additionalProperties: false
PushMetricsResult:
type: object
properties:
ok: { type: boolean }
channel: { type: string }
stored:
type: array
items: { type: string }
note: { type: string }
PushEarnedResult:
type: object
properties:
ok: { type: boolean }
channel: { type: string }
month: { type: string, examples: ["2026-07-01"] }
status:
type: string
enum: [settled, partial]
description: |
Derived here, never taken from the caller: the month you are in is
partial by definition and never sums into a lifetime total.
note: { type: string }
PushCostResult:
type: object
properties:
ok: { type: boolean }
project: { type: string }
cost: { type: string }
created:
type: boolean
description: False when an existing bill of that label was updated.
note: { type: string }
Money:
type: [number, "null"]
description: |
Canonical USD. **Null means not reported** — never zero. A rail that
does not publish a figure leaves it null, and so does a cost nobody has
entered; treating either as 0 turns an unknown into a claim.
Portfolio:
type: object
properties:
pulledAt:
type: string
format: date-time
description: When the snapshot behind these figures was taken.
currency:
type: string
examples: ["USD (canonical)"]
plan:
type: string
examples: [free, pro]
historyDays:
type: integer
description: |
The window the trend actually covers, which on a free plan is
shorter than the one requested. Label charts from this, not from
what you asked for.
windowNote:
type: string
description: Present only when the plan shortened the window.
portfolio:
type: object
properties:
mrrUsd: { $ref: "#/components/schemas/Money" }
costMonthlyUsd: { $ref: "#/components/schemas/Money" }
netMonthlyUsd: { $ref: "#/components/schemas/Money" }
activeSubs: { type: [integer, "null"] }
users: { type: [integer, "null"] }
trend:
type: array
description: Portfolio net over the window, one point per pull.
items: { $ref: "#/components/schemas/Money" }
earnedYear: { type: [integer, "null"] }
earnedThisYearUsd: { $ref: "#/components/schemas/Money" }
earnedTotalUsd: { $ref: "#/components/schemas/Money" }
earnedThisMonthSoFarUsd:
allOf:
- $ref: "#/components/schemas/Money"
description: |
The month still running, deliberately kept out of the two totals
above. Null means nothing in scope reports before a month closes.
earnedThisMonthMissingRails:
type: array
items: { type: string }
description: |
Rails that report nothing until a month closes, so the
month-to-date figure is known to be incomplete and says which
parts are absent.
earnedSeries:
type: [object, "null"]
additionalProperties: true
description: |
Twelve slots per year, null where nothing was measured. Null and
zero differ: an unreported month is unknown, not a month of no
money.
projects:
type: array
description: Worst net margin first; unknown net sorts last.
items: { $ref: "#/components/schemas/ProjectRow" }
required: [pulledAt, portfolio, projects]
ProjectRow:
type: object
properties:
project: { type: string, format: uuid }
name: { type: string }
mrrUsd: { $ref: "#/components/schemas/Money" }
costMonthlyUsd: { $ref: "#/components/schemas/Money" }
netMonthlyUsd: { $ref: "#/components/schemas/Money" }
activeSubs: { type: [integer, "null"] }
users: { type: [integer, "null"] }
churnedSubs: { type: [integer, "null"] }
oneOffRevenueUsd:
allOf:
- $ref: "#/components/schemas/Money"
description: Kept out of net on purpose — one-off money is not a run rate.
channels:
type: array
items:
type: object
properties:
channel: { type: string }
rail: { type: string }
mrrUsd: { $ref: "#/components/schemas/Money" }
additionalProperties: true
NewCost:
type: object
properties:
label: { type: string, minLength: 1 }
amount: { type: number, minimum: 0 }
currency: { type: string, pattern: "^[A-Z]{3}$" }
period:
type: string
enum: [monthly, yearly, once]
default: monthly
incurredOn:
type: string
format: date
description: |
Required when `period` is `once`, and refused otherwise. A purchase
is placed by its date and by nothing else; defaulting it to today
would put it in the wrong month, and the month is the whole point.
scope:
type: string
enum: [project, shared]
default: project
projectId:
type: string
format: uuid
description: Required when `scope` is `project`.
sharedProjects:
type: array
items: { type: string, format: uuid }
description: |
Required when `scope` is `shared` — the bill is split equally
across them. A one-off cannot be shared.
required: [label, amount, currency]