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" } }| Status | Meaning |
|---|---|
401 | Missing or invalid API key |
402 | Out of credits |
404 | Not found, or not yours |
409 | Duplicate project name |
429 | Rate limit exceeded |
Account
/api/v1/meThe 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.
/api/v1/projectsList your projects, most recently updated first.
/api/v1/projectsCreate 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"}'/api/v1/projects/:projectIdFetch one project.
/api/v1/projects/:projectIdUpdate 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"}}'/api/v1/projects/:projectIdDelete 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.
/api/v1/jobsStart 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" } }/api/v1/jobsList your jobs, newest first. Add ?projectId= to filter to one project.
/api/v1/jobs/:jobIdFetch 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.
/api/v1/projects/:projectId/audit/latestThe 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 }
}
}
}/api/v1/projects/:projectId/findingsThe 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
}
] } }/api/v1/projects/:projectId/findings/:findingIdSet 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) };
},
};