DevMargin Push API
DevMargin pulls revenue from your billing rails on its own. But some numbers no rail can know:
- User counts — Stripe knows subscribers, not users. Your app knows.
- Revenue collected outside a rail — bank transfers, invoices, lifetime deals, retainers.
- Your running costs — the hosting and API bills that turn revenue into net margin.
The Push API is how your product reports those. Everything you push is stored as latest state and merged into the next daily snapshot, so history stays in one place and every figure remains traceable.
1. Get your ids and a token
Channel or project id — open Projects. Every project heading and channel row carries a copyable id chip. Click it.
- Pushing metrics (users, revenue) → you need a channel id.
- Pushing costs → you need a project id.
Which channel?
- If the numbers are revenue no rail sees, create a channel with the rail Push (your app reports) — it needs no connection.
- If the channel already draws from a billing rail, use it as-is and push only
users. Money there belongs to the rail (see the double-counting rule below).
Token — Settings → Push API → create one. It starts with dmp_ and is shown once. Push tokens can only write; they cannot read your portfolio.
2. The endpoint
> Machine-readable: the whole HTTP surface — this endpoint and the signed-in > read API — is described in OpenAPI 3.1 at > `/api/openapi`. Point Swagger UI, Postman > or a client generator at it rather than transcribing the tables below.
POST https://devmargin.app/api/push
Authorization: Bearer dmp_...
Content-Type: application/jsonShape A — metrics for a channel
{ "channel": "<channel-id>", "metrics": { "users": 1234 } }All metric fields are optional. Send only what you actually know:
| Field | Type | Notes |
|---|---|---|
users | integer | The only field a rail-backed channel accepts |
activeSubs, newSubs, churnedSubs | integer | |
mrr, arr, oneOffRevenue | number | Push-rail channels only |
currency | string | ISO-4217, e.g. "EUR". Converted to USD at snapshot FX |
Shape B — a month that has already been earned
{
"channel": "<channel-id>",
"earned": { "month": "2026-07", "amount": 48200, "currency": "CZK", "basis": "proceeds" }
}Shape A carries latest state — what your product is earning now. This carries a dated fact: what July earned. They answer different questions and the dashboard uses them in different places, so a seasonal product needs both.
| Field | Type | Notes |
|---|---|---|
month | string | "YYYY-MM". The current month is accepted and marked partial; a future one is refused |
amount | number | In currency, >= 0 |
currency | string | ISO-4217 |
basis | string | Required. "gross" before any platform fee, "proceeds" after it |
basis has no default deliberately. "48200 CZK" is a different fact depending on whether a marketplace already took its cut, and a portfolio total that silently mixed the two would be wrong in a way nobody could see.
Push-rail channels only: a channel backed by Stripe or the App Store gets its months from that rail. Upsert keyed by month, so re-pushing a corrected figure replaces it rather than adding to it. The USD figure is filled in at the next pull, from that run's rates — the same single FX source every other figure uses.
Don't push the same money through both shapes. If a month's takings go through earned, leave oneOffRevenue out of your metrics push, or the same money is described twice in two places.
Shape C — a running cost for a project
{ "project": "<project-id>", "cost": { "label": "OpenAI", "monthly": 42.10, "currency": "USD" } }Upsert keyed by label, so re-pushing the same label every day updates one row instead of piling up duplicates. Amounts are monthly — divide yearly bills by twelve. Costs apply to net margin immediately; no pull needed.
Responses
| Status | Meaning |
|---|---|
200 | Stored |
400 | Bad or unknown field, or an explicit null |
401 | Missing or invalid token |
403 | Wrong token kind (a read/MCP token cannot write) |
404 | That channel or project isn't in your account |
422 | Money pushed to a rail-backed channel |
429 | Over 60 pushes per hour for this token |
3. Two rules worth understanding
Omit what you don't know. Never send 0 to mean "unknown", and never send null — it's rejected. A missing field shows as — in the dashboard and stays honestly unknown. A fabricated zero silently corrupts your net-margin maths, which defeats the point of the product.
One source per channel. A channel's money comes from exactly one place. If a channel draws from Stripe, RevenueCat, the App Store or Google Play, then pushing mrr there would double-count what the rail already reports — so it's refused with 422. Only users is accepted, because no rail reports it. For revenue no rail sees, use a push-rail channel.
4. Security
The token is a write credential. If it leaks, someone can write junk numbers into your dashboard — they cannot read it. Still, treat it as a secret:
- Never ship it in a mobile app or client-side JavaScript. A token in an iOS or Android binary is extractable by anyone who downloads it. Push from a server: your backend, a cron job, a scheduled function, a CI job, or a script you run. (Your user counts live in your database anyway, not on the device.)
- One token per app, named after it. Then revoking one doesn't break the others, and last used in Settings tells you which are still alive.
- Environment variable only — never committed, never logged, never echoed in CI output or error messages.
- Rotating: create a new token, update the environment variable, then revoke the old one.
Pushing is idempotent (it's an upsert of latest state), so retries are safe, and nothing can be deleted through the API. The worst outcome of a bad push is a wrong latest value, corrected by the next one.
5. How often?
Whenever your numbers change — but once a day is plenty, because the snapshot that reads them runs daily at 05:00 UTC. The rate limit is 60 pushes per hour per token. Values older than seven days still count, but the snapshot notes how stale they are rather than quietly presenting them as current.
6. Minimal example
curl -X POST https://devmargin.app/api/push \
-H "Authorization: Bearer $DEVMARGIN_PUSH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel":"<channel-id>","metrics":{"users":1234}}'7. Copy-paste prompt for an AI coding agent
Paste this into Claude Code (or a similar agent) inside the repository of the app that should report its numbers:
Add a nightly push of this project's metrics to DevMargin (https://devmargin.app),
a portfolio dashboard showing revenue and net margin across billing rails. It can't see
things only this app knows — user counts, revenue collected outside a billing rail — so
we push those. Full reference: https://devmargin.app/docs/push-api
ENDPOINT CONTRACT
POST https://devmargin.app/api/push
Headers: Authorization: Bearer <DEVMARGIN_PUSH_TOKEN>, Content-Type: application/json
Three body shapes (one per request):
1) { "channel": "<uuid>", "metrics": { ... } }
Latest state. Optional fields — send only what you actually know:
users (int), activeSubs (int), newSubs (int), churnedSubs (int),
mrr (number), arr (number), oneOffRevenue (number), currency ("EUR", ISO-4217)
IMPORTANT: if the channel is backed by a billing rail (Stripe, RevenueCat, App
Store, Google Play), ONLY "users" is accepted — money is rejected with HTTP 422
because it would double-count what the rail already reports.
2) { "channel": "<uuid>", "earned": { "month": "2026-07", "amount": 48200,
"currency": "CZK", "basis": "proceeds" } }
A month that already happened, for the Earned history and lifetime totals.
Push-rail channels only. basis is REQUIRED: "gross" (before any platform fee)
or "proceeds" (what reached you) — there is no default, because the same number
means different things and mixing them corrupts a portfolio total invisibly.
Upsert keyed by month, so a corrected figure replaces the old one. Do not also
report that money as oneOffRevenue in shape 1.
3) { "project": "<uuid>", "cost": { "label": "OpenAI", "monthly": 42.10, "currency": "USD" } }
Upsert keyed by label, so re-pushing daily is idempotent. Monthly amounts —
divide yearly bills by 12.
Responses: 200 ok · 400 bad field · 401 bad token · 403 wrong token kind · 404 unknown id
· 422 money on a rail-backed channel · 429 rate limit (60/hour/token).
HONESTY RULE (non-negotiable — it is the point of the product)
Omit any field you cannot compute. NEVER send 0 to mean "unknown", and never send null
(it is rejected). A missing field stays honestly unknown; a fabricated 0 silently
corrupts net-margin maths.
SECURITY REQUIREMENTS
- The token is a WRITE credential. It lives only in a server-side environment variable
named DEVMARGIN_PUSH_TOKEN. Never in a mobile app binary, never in client-side JS,
never committed, never logged, never printed in error messages or CI output.
- If this project is a mobile app with no backend, do NOT push from the device. Push from
whatever server-side source of truth actually holds the numbers (database, analytics
job, CI). If there is none, say so and stop rather than shipping a token to devices.
- Add DEVMARGIN_PUSH_TOKEN to .env.example (empty) and confirm .env is git-ignored.
TASK
1. Inspect this repo and tell me FIRST: what the project is, where its user count
genuinely comes from (a DB query? an auth provider API? analytics?), and which
server-side mechanism fits a once-a-day push — an existing cron route, a Vercel cron,
a Supabase Edge Function with pg_cron, a GitHub Action, or a script I run. Recommend
one and wait for my OK before writing code.
2. Then implement it:
- Compute the metrics from the real source of truth. If a number isn't reliably
computable, omit the field and tell me which and why — do not invent it.
- POST with a short timeout, and make the whole thing non-fatal: a DevMargin outage
must never break this app or fail a deployment.
- Log one line (status + which fields were sent), with the token redacted.
- No new dependencies for the HTTP call — use the platform's built-in fetch or curl.
3. Verify: run it once against production, show me the HTTP status and response body, and
confirm the token appears nowhere in the repo (grep) or in the log output.
I WILL PROVIDE (ask me — don't guess):
- DEVMARGIN_PUSH_TOKEN (starts with dmp_) — from DevMargin Settings → Push API
- The channel id — copy the "channel id" chip on https://devmargin.app/projects
- The project id, if we're pushing costs — copy the "project id" chip on the same page