API Reference

A REST API for managing projects and running jobs. Every endpoint lives under /api/v1 and authenticates with a per-user API key.

The same operations are available to AI agents through the MCP server.

Authentication

Generate a key in Settings. Pass it as a Bearer token. Keys are shown once at creation and stored hashed — generating a new key revokes the previous one.

curl https://ads.daho.ai/api/v1/me \
  -H "Authorization: Bearer sk_..."

Requests are rate limited to 60 per minute per user. Exceeding it returns 429.

Responses

Successful responses wrap the payload in data. Errors carry a machine-readable code and a human-readable message.

{ "data": { "id": 1, "name": "My project" } }

{ "error": { "code": "NOT_FOUND", "message": "Project not found" } }
StatusMeaning
401Missing or invalid API key
402Out of credits
404Not found, or not yours
409Duplicate project name
429Rate limit exceeded

Account

GET/api/v1/me

The authenticated user's profile, project count, and remaining credits.

{
  "data": {
    "id": "usr_...",
    "name": "Ada Lovelace",
    "email": "[email protected]",
    "role": "user",
    "billingModel": "credits",
    "usage": {
      "projects": { "current": 3, "limit": null },
      "credits": { "current": 27, "costPerJob": 1 }
    }
  }
}

Projects

A project groups jobs and assistant sessions. The metadata object is free-form — use it for your own domain fields without a migration.

GET/api/v1/projects

List your projects, most recently updated first.

POST/api/v1/projects

Create a project. The slug is derived from the name; a duplicate name returns 409.

curl -X POST https://ads.daho.ai/api/v1/projects \
  -H "Authorization: Bearer sk_..." \
  -H "Content-Type: application/json" \
  -d '{"name": "My project", "description": "Optional"}'
GET/api/v1/projects/:projectId

Fetch one project.

PATCH/api/v1/projects/:projectId

Update name, description, status (active or archived), or metadata.

curl -X PATCH https://ads.daho.ai/api/v1/projects/1 \
  -H "Authorization: Bearer sk_..." \
  -H "Content-Type: application/json" \
  -d '{"status": "archived", "metadata": {"tier": "pro"}}'
DELETE/api/v1/projects/:projectId

Delete a project and cascade-delete its jobs. Assistant sessions are kept but unlinked.

Jobs

Jobs run asynchronously and cost one credit each. Starting a job returns immediately with an id — poll for the result. If the handler fails, the job is marked failed and the credit is refunded automatically.

POST/api/v1/jobs

Start a job. Returns 402 if you are out of credits, or 400 for an unknown type.

curl -X POST https://ads.daho.ai/api/v1/jobs \
  -H "Authorization: Bearer sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "projectId": 1,
    "type": "ads_audit",
    "input": { "accounts": { "google": { "accountId": "776-992-4543" } } }
  }'

{ "data": { "jobId": 42, "status": "queued" } }
GET/api/v1/jobs

List your jobs, newest first. Add ?projectId= to filter to one project.

GET/api/v1/jobs/:jobId

Fetch a job's status and result. Status is queued, running, completed, or failed.

{
  "data": {
    "id": 42,
    "projectId": 1,
    "type": "ads_audit",
    "status": "completed",
    "input": { "accounts": { "google": { "accountId": "776-992-4543" } } },
    "result": { "aggregateScore": 62.4, "aggregateGrade": "C", "platforms": ["..."] },
    "error": null,
    "startedAt": "2026-09-01T12:00:01Z",
    "finishedAt": "2026-09-01T12:00:09Z"
  }
}

Audits and findings

An audit's job result is the full 161-control document — tens of kilobytes per platform. These two endpoints are the readable view of it: a summary of the latest run, and the ledger of what is still outstanding.

GET/api/v1/projects/:projectId/audit/latest

The newest completed audit, summarised. Returns null if the project has never been audited.

{
  "data": {
    "audit": {
      "jobId": 42,
      "generatedAt": "2026-09-01T12:00:09Z",
      "aggregateScore": 62.4,
      "aggregateGrade": "C",
      "results": { "pass": 6, "warning": 14, "fail": 4, "na": 56 },
      "platforms": [
        {
          "platform": "google",
          "healthScore": 62.4,
          "grade": "C",
          "coverage": { "evaluated": 24, "notApplicable": 56, "total": 80 },
          "weakestCategories": [
            { "category": "Wasted Spend / Negatives", "score": 35.4, "passed": 1, "total": 6 }
          ]
        }
      ],
      "quickWins": ["..."],
      "criticalIssues": ["..."],
      "movement": { "new": 2, "regressed": 1, "still_open": 8, "fixed": 3, "unchanged": 0 }
    }
  }
}
GET/api/v1/projects/:projectId/findings

The findings ledger, worst first. Filter with ?status=open,in_progress &platform=google &limit=25.

curl "https://ads.daho.ai/api/v1/projects/1/findings?status=open&platform=google&limit=2" \
  -H "Authorization: Bearer sk_..."

{ "data": { "findings": [
  {
    "id": 45,
    "controlId": "G14",
    "platform": "google",
    "category": "Wasted Spend / Negatives",
    "name": "Negative keyword lists exist",
    "severity": "critical",
    "status": "open",
    "lastResult": "FAIL",
    "recommendation": "Create at least 3 theme-based negative keyword lists...",
    "fixTimeMinutes": 10
  }
] } }
PATCH/api/v1/projects/:projectId/findings/:findingId

Set a finding's status and note. This is the user's half of the ledger.

curl -X PATCH https://ads.daho.ai/api/v1/projects/1/findings/45 \
  -H "Authorization: Bearer sk_..." \
  -H "Content-Type: application/json" \
  -d '{ "status": "in_progress", "note": "Building the lists this week" }'

A finding carries two independent states, and the split is the point. status is yours — open, in_progress, fixed, wont_fix, snoozed — while lastResult is the audit's verdict. Because they are separate, marking something wont_fix survives every future audit, and a control you marked fixed that fails again is reported as a regression rather than quietly reopening.

Job types

One job type is registered: ads_audit. It takes { accounts: { google|meta|tiktok: { accountId, data?, budgetShare? } } } and returns a scored run — an aggregate score and grade, a per-platform breakdown, quick wins, and critical issues. Omit data to read the account from a connected source; supply it to audit a pasted export.

Register your own by adding an entry to JOB_HANDLERS in lib/jobs/handlers.ts. Nothing else needs to change — the credit accounting, refund path, completion email, and this endpoint all work off the registry.

export const JOB_HANDLERS: Record<string, JobHandler> = {
  ads_audit: adsAudit,
  myJob: async (input) => {
    const { url } = myJobInput.parse(input);
    return { processed: await doTheWork(url) };
  },
};