DevMargin API Openapi API

DevMargin pulls revenue from billing rails, subtracts each project's running

Operations 6

POST /api/push Report metrics, an earned month, or a running cost #
GET /api/v1/portfolio Portfolio totals, per-project rows and the net trend #
GET /api/v1/trend Portfolio net margin over time, one point per pull #
GET /api/v1/projects/{name} One project, its channels and its costs #
POST /api/v1/costs Add a cost #
POST /api/v1/refresh Pull every connected rail now #

Work with this as data

Every API here is available over the APIs.io API and to AI agents over MCP.

MCP server

One button, every client — Claude, Cursor, VS Code and the rest.

https://apis.io/mcp

Tools for apis

7 MCP tools reach this
  • find_apisBrowse and filter every API in the catalog.
  • get_api_artifactsOne API's artifacts, grouped by type.
  • get_openapiThe primary OpenAPI for this API.
  • find_similar_apisAPIs that look like this one.
  • apis_io_searchSTART HERE — APIs, providers and tags for one query, each with its total.
  • resolveTurn a domain, URL or GitHub org into the provider it belongs to.
  • find_cohortsEvery scored population of providers in the catalog.
All 92 tools →

Call it yourself

curl for this page
This API
curl "https://apis.io/api/v1/apis/devmargin-app:devmargin-api-openapi-api"
All apis
curl "https://apis.io/api/v1/apis?limit=25"

Calls without X-API-Key need no key. Calls with it need a paid plan — get a key.

Get an API key

Free tier, no form to fill in. Signing in shares your email address with us — we store it to create your key and to recognise you if you sign in with another provider. See our Privacy Policy and Terms.

A second provider on the same verified email joins the account you already have.

OpenAPI Specification

devmargin-app-openapi-openapi.yml Raw ↑
# 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]