API

Start the same research the site runs, over HTTP, and fetch the finished report. Each search spends your account's credits at the prices on Pricing.

Get a key

Create one under Account, Connections, API. You see the full key once, so copy it when it appears.

Manage API keys

Quick start

1. Start a search (depth: quick = 105 credits, default = 170, deep = 240; an "A vs B" comparison costs 190 / 300 / 425; ending the query with --competitors, or --competitors=N for 1 to 3 rivals, finds the topic's closest rivals and runs it as that comparison at the comparison price, or as the topic alone if none are found). A successful create is accepted with HTTP 202 and returns a search_id immediately:
IDEMPOTENCY_KEY=$(python3 -c 'import uuid; print(uuid.uuid4())') curl -i -X POST https://last30days.dev/api/v1/search \ -H "Authorization: Bearer sk_live_your_key_here" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $IDEMPOTENCY_KEY" \ -d '{"query": "AI coding agents", "depth": "default", "clarify": {"run_broadly": true}}' # -> HTTP 202 # -> {"search_id":"<uuid>","status":"running","query":"AI coding agents"}
This example opts out of the optional clarification step. If you omit clarify and receive HTTP 200 with needs_clarification, the body carries clarification_kind (identity or scope), identity_answer_enabled (always true), options (display labels), angles (each label paired with a complete sharpened query that preserves the original query's entities, e.g. "Argentina vs Spain" -> "Argentina vs Spain for the recent football matchup"), and original_query. Identity questions may have no preset options. To answer one, re-POST the original query with clarify: {answer: {kind: "identity", text: "The company at every.to"}}. The complete description (1–1000 Unicode characters) is stored separately from the research query as unverified user intent. Typed scope uses kind "scope" and a short focus phrase. Invalid or unsupported answers return 422 invalid_request before reservation; keep the answer editable. Comparison identity corrections are currently unsupported. Do not put a website into a scope answer. Re-POST the same original query with clarify: {angle: <picked angle's query>} - not the label itself; an angle that does not preserve the original query's entities (such as re-posting a bare label) is rejected, and the original query is dispatched instead. To repeat or save an existing report, use source_search_id with an optional matching query; it cannot be combined with clarify. Access and eligibility are checked server-side, and the private intent snapshot is inherited without appearing in responses or URLs. Set force_fresh to true to always start new research. Source-based requests also run fresh. Eligible requests may receive an independent copy of recent public-topic research at the normal price, with the original research date retained; a 202 response can already have status complete. Poll responses include research_started_at for the original research time; created_at is when your independent report was created. Older reports may have no research_started_at. Omitted or false force_fresh keeps the same request identity; changing it to true requires a new idempotency key. Optional context (up to 500 characters) says who or what the query means when you already know: the subject's handles or a one-line description. It is an unverified hint for finding the right subject, never instructions; a handle the web does not confirm is not used. A request with context always runs fresh, cannot be combined with source_search_id or a --competitors query, and is part of the request identity, so different context needs a new idempotency key. New 202 responses additively echo query - the accepted research query, sharpened or original; idempotent replay responses omit it. Poll after HTTP 202 to retrieve the report, including when it is already complete. Keep the same idempotency key only when retrying this exact request after a timeout or lost response. Generate a new key for every intentional new search, including a later run of the same query.
2. Poll every few seconds until status is "complete" (a run takes a few minutes). The report is in synthesis_text / raw_markdown. A meeting-brief run you stopped, by skipping its meeting or turning automatic briefs off, ends with status stopped instead: it is not charged and has no report. Responses also carry research_access: public, owner, withheld or none. A meeting-brief run's research, raw_markdown and progress go only to the account that ran it; anyone else gets raw_markdown null and research_access withheld. The report and its research are also Markdown files at /r/<id>.md (or /r/<id> with Accept: text/markdown) and /r/<id>/research.md. No key is needed, except for a meeting-brief run's research, which takes its owner's key. For bounded live progress, start with progress_cursor=0 and send each response's progress.next value on the next request:
curl "https://last30days.dev/api/v1/search?id=<search_id>&progress_cursor=0" \ -H "Authorization: Bearer sk_live_your_key_here" # while active -> {"status":"running","progress":{"from":0,"next":12000,"delta":"...","has_more":true,"reset":false}, ...} # when done -> {"id":"...","status":"complete","synthesis_text":"...", ...}
Tip: pipe step 1 through jq -r .search_id to capture the id for step 2. Treat progress cursors as opaque; append delta in order, and replace local progress when reset is true.

Your searches

List what ran on your account, newest first, as My reports does: searches, scheduled runs, meeting briefs and Slack runs. A meeting-brief run you stopped is left out; poll its id to see it. Each has id, query, status, partial, created_at, research_started_at, source and subscription_id. limit is 1 to 50 (default 20). To get the next page, send next_before as before; it is null on the last page. active=true returns only runs still pending or running, however old. A bad limit, before or active answers 400:
curl "https://last30days.dev/api/v1/searches?limit=20" -H "Authorization: Bearer sk_live_your_key_here" # -> {"searches":[{"id":"<uuid>","query":"AI coding agents","status":"complete", ...}],"next_before":"2026-10-01T12:00:00.123456+00:00"} curl -G https://last30days.dev/api/v1/searches --data-urlencode "before=2026-10-01T12:00:00.123456+00:00" -H "Authorization: Bearer sk_live_your_key_here"

Credits and billing

Your balance: credits, reserved (held for runs in progress), credit_debt from a refund, available_credits to spend, and recent_usage:
curl https://last30days.dev/api/v1/credits -H "Authorization: Bearer sk_live_your_key_here" # -> {"credits":600,"reserved":0,"credit_debt":0,"available_credits":600, ...}
The Billing page's Activity list, newest first: up to 50 reports, each with what it charged (charge is settled, partial, running, no-charge, reconciling or free, and label is the text the page shows), and up to 20 purchases, each with its pack, what it added and any refund or dispute, and paid_usd. show is all (the default), reports or purchases. Payment and card details are never included. Credits are bought on the site:
curl "https://last30days.dev/api/v1/credits/activity?show=all" -H "Authorization: Bearer sk_live_your_key_here" # -> {"activity":[{"kind":"report","at":"...","search_id":"<uuid>","query":"AI coding agents","charge":"settled","label":"170 credits", ...}, # {"kind":"purchase","at":"...","label":"Curious pack","detail":"+1,000 credits","paid_usd":10}]}

Scheduled reports

Everything on the Scheduled tab works with the same key. Each scheduled run spends credits at the search prices above, and an account can have 10 active scheduled reports. Delivery windows are two hours long and set in your home time zone. Errors come back as {"error": "..."} with the status: 400 for a bad field, 401 for a missing or revoked key, 402 with requires_credits, balance and needed when your credits cannot cover one run, 403 at the active limit or for a Slack workspace you have not connected, 404 for a schedule that is not yours (DELETE answers ok either way), 422 when the schedule would have no destination or your account has no time zone, and 429 when the key is over its rate limit.

List your schedules. Each has id, query, depth, cadence, delivery_weekday (0 is Sunday, null for daily), delivery_hour, status (active, paused or needs_credits), paused_reason (customer, failure, plan_limit, staff, no_destination, or access_revoked when the key or app that created it was removed), created_via (site, api_key, mcp, staff when our team set it up for you, or null for older schedules), next_run_at, last_run_at, consecutive_failures, created_at, deliver_email, email_separate, slack_channel_id, slack_channel_name, slack_installation_id and slack_delivery_error (set when Slack refused a post; pick the channel again to clear it):
curl https://last30days.dev/api/v1/subscriptions \ -H "Authorization: Bearer sk_live_your_key_here" # -> {"subscriptions":[{"id":"<uuid>","query":"AI coding agents","cadence":"weekly", ...}]}
Create one. Send query, or source_search_id to repeat a finished report. cadence is daily or weekly; delivery_weekday defaults to Monday and delivery_hour, an even hour from 0 to 22, to 8. time_zone is saved as your home time zone only when you have none. Email is on unless deliver_email is false; email_separate sends it on its own instead of in your combined briefing. For Slack, add slack_installation_id, slack_channel_id and slack_channel_name from the channel list below.
curl -X POST https://last30days.dev/api/v1/subscriptions \ -H "Authorization: Bearer sk_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{"query": "AI coding agents", "cadence": "weekly", "delivery_weekday": 1, "delivery_hour": 8, "time_zone": "America/New_York"}' # -> {"id":"<uuid>","next_run_at":"...","next_window_at":"...","first_delivery":null}
next_window_at is when the first report arrives. A schedule made from source_search_id sends that report right away, free, and first_delivery says how that went: sent, already_sent, in_progress, not_sent or unknown. Scheduling the same report again returns the existing schedule with existing: true and retries any destination that did not get it. A query is never matched to an existing schedule.
Change one with PATCH /api/v1/subscriptions/<id>. Each request changes one of the groups below, and returns {"ok":true}. Fields from a second group in the same request are ignored, so send one request per group:
{"status": "paused"} # or "active" to resume {"cadence": "daily", "delivery_hour": 18} # weekly also takes delivery_weekday {"deliver_email": false, "email_separate": true} # either field alone works too {"slack_installation_id": "<id>", "slack_channel_id": "C123", "slack_channel_name": "news"} {"slack_channel_id": null, "slack_channel_name": null} # stop posting to Slack
Choosing a channel joins it first, and a 400 with reason says why Slack refused. Turning off the last destination is refused with 422; turn the other one on first.
Delete one, or start its research now instead of waiting for its window. A run now costs the same as a scheduled run and returns a search_id to poll like any search. With a key it counts toward the per-account limit on starting searches, and answers 429 when you are over it:
curl -X DELETE https://last30days.dev/api/v1/subscriptions/<id> -H "Authorization: Bearer sk_live_your_key_here" curl -X POST https://last30days.dev/api/v1/subscriptions/<id>/run -H "Authorization: Bearer sk_live_your_key_here" # -> {"search_id":"<uuid>"}
Read or change your home time zone. Changing it moves every active schedule to the same local window in the new zone:
curl https://last30days.dev/api/v1/account/time-zone -H "Authorization: Bearer sk_live_your_key_here" # -> {"time_zone":"America/New_York"} curl -X PUT https://last30days.dev/api/v1/account/time-zone \ -H "Authorization: Bearer sk_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{"time_zone": "Europe/London"}'
List the Slack channels a schedule can post to. Connect a workspace on the site first, under Account, Connections, Slack:
curl https://last30days.dev/api/v1/slack/channels -H "Authorization: Bearer sk_live_your_key_here" # -> {"workspaces":[{"installation_id":"<id>","team_name":"Acme","status":"active","channels":[{"id":"C123","name":"news"}]}]}

Meeting briefs

Everything on the Meetings page works with the same key. Connect Google Calendar on the site first, under Account, Connections, Google Calendar. Each brief spends credits, up to per_brief_cost_credits. Errors come back as {"error": "..."} with the status: 400 for a bad field, 401 for a missing or revoked key, 404 for a meeting or connection that is not yours, 409 when briefs are waiting for credits or a meeting cannot take that action now, and 429 when the key is over its rate limit.

Read your briefs: calendar (connection_id, google_email, status, or null when none is connected), settings (enabled, status active, paused or needs_credits, daily_cap, internal_domains, the skip list of domains and email addresses, and ignored_internal_domains, the free email domains on it that skip no one), per_brief_cost_credits, pause_reasons while paused for errors, upcoming meetings (id, title, event_start, status, status_reason, connection_id, event_id, recurring_event_id, person_query, company_query, target_plan_pending, worst_case_cost_credits, which is null while the people and company to research are still being chosen, scheduled_by, deliver, person, include_person, include_company, external_attendees, company_reuse, send_at, research_running, person_search_id and company_search_id), left_out (counts by reason of calendar events in the next 7 days that are not listed), left_out_meetings (those events: connection_id, event_id, title, event_start, reason, reason_label, and briefable, true when it was left out only because it looked personal) and recent outcomes from briefs you ran or scheduled. Outcomes include occurred_at, person_search_id and company_search_id. Unused disconnect cancellations are omitted. Pass the returned history_cursor as a query parameter to read the next 25 older outcomes; a null history_cursor means there are no older outcomes. Status eligible means the meeting is listed but not scheduled, and eligible meetings are listed for the next 7 days. Status skipped with status_reason user_skipped, run_now_moved or cap means you skipped it, a Run now meeting moved, or it went over the daily cap; run or schedule it to bring it back. Report ids are set once research starts, and research_running stays true until both reports finish. scheduled_by is automatic for automatic briefs and manual for meetings you ran or scheduled, which never count against daily_cap; deliver none means it was run without an email. company_reuse (search_id and completed_at) names your company report from the last 7 days that the brief links instead of running, and the cost already leaves it out:
curl https://last30days.dev/api/v1/briefs -H "Authorization: Bearer sk_live_your_key_here" # -> {"connected":true,"settings":{"enabled":false,"status":"active","daily_cap":3, ...},"upcoming":[...], ...}
Change settings with PATCH. Send any of enabled (automatic briefs, which schedule every listed meeting; off until you turn them on), daily_cap (1 to 10 automatic briefs a day) and internal_domains (the whole skip list, up to 100 domains such as acme.com or emails such as sam@gmail.com; meetings where everyone else is on it are not briefed). Upcoming briefs change at once, and the response is the new settings. Turning automatic briefs off stops their research under way, and research that has not finished is not charged: an automatic brief with a finished report it paid for keeps it and still emails, and one with nothing finished goes back to eligible and no longer counts against daily_cap. Meetings you ran or scheduled keep running:
curl -X PATCH https://last30days.dev/api/v1/briefs \ -H "Authorization: Bearer sk_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{"daily_cap": 2, "internal_domains": ["acme.com", "sam@gmail.com"]}'
Resume briefs after they paused for repeated errors. Briefs waiting for credits restart when you add credits. A 202 with rearm pending means briefs are on and upcoming meetings are still being re-armed:
curl -X POST https://last30days.dev/api/v1/briefs/resume -H "Authorization: Bearer sk_live_your_key_here" # -> {"resumed":true,"rearm":"complete"} or {"resumed":false,"already_active":true}
If the calendar briefs watch is no longer available, resume answers 409. Choose a calendar instead, below.
Choose the calendar briefs watch. GET lists the calendars your connection can watch: connection_id, current (id and name, the one watched now) and calendars (id, name and kind: primary, owned, person or shared). Holiday, birthday and other subscribed calendars are left out. On a colleague's calendar, kind person, briefs treat its owner as you. PUT switches to one with calendar_id, an id GET returns, and answers with calendar (id and name) and changed:
curl https://last30days.dev/api/v1/briefs/calendar -H "Authorization: Bearer sk_live_your_key_here" # -> {"connection_id":"<uuid>","current":{"id":"primary","name":"you@acme.com"}, # "calendars":[{"id":"primary","name":"you@acme.com","kind":"primary"},{"id":"boss@acme.com","name":"Boss","kind":"person"}]} curl -X PUT https://last30days.dev/api/v1/briefs/calendar \ -H "Authorization: Bearer sk_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{"calendar_id": "boss@acme.com"}' # -> {"calendar":{"id":"boss@acme.com","name":"Boss"},"changed":true}
Switching cancels the scheduled briefs for the old calendar. Briefs already researched are sent only if their meeting is on the new calendar, and the new calendar's meetings appear after its next sync. Choosing the current calendar again returns changed false and restarts briefs paused because that calendar was unavailable. Errors: 400 when calendar_id is missing or not one GET lists; 404 when no Google Calendar is connected; 409 with reconnect_url when Google has not given Last30Days your calendar list (open it in a browser to go back through Google, then try again); 409 without it when the connection changed during the switch (read again and retry); 502 when Google could not be read.
Act on one upcoming meeting by its id. run researches it now and answers once its research is claimed or queued: status research_claimed when the reports have started, or armed when choosing who to research or starting the reports will be retried; a meeting nobody scheduled sends no email, a scheduled one keeps its email. It needs the meeting at least 15 minutes away. schedule emails a brief an hour before the meeting and needs it at least 90 minutes away (409 with remedy run_now inside that); on a meeting already run it emails those reports without running them again. skip drops the meeting for good: no brief email is sent, research still under way stops and is not charged, and reports that already finished stay charged and in your reports. skip answers 409 while the brief email is being sent. schedule or run brings a skipped meeting back and charges again for its research. Each answers with the meeting: id, status, scheduled_by, deliver, person, include_person, include_company, person_query and company_query. A 409 can carry remedy buy_credits, resume, reconnect or run_now:
curl -X POST https://last30days.dev/api/v1/briefs/<id>/run -H "Authorization: Bearer sk_live_your_key_here" curl -X POST https://last30days.dev/api/v1/briefs/<id>/schedule -H "Authorization: Bearer sk_live_your_key_here" curl -X POST https://last30days.dev/api/v1/briefs/<id>/skip -H "Authorization: Bearer sk_live_your_key_here" # -> {"id":"<id>","status":"research_claimed","scheduled_by":"manual","deliver":"none", ...}
Brief a meeting from left_out_meetings that is briefable. Send its connection_id and event_id, and mode schedule or run, which work like schedule and run above. The meeting joins upcoming as yours and is not left out again for looking personal; if the action is refused, it joins as skipped so you can retry with its id. 409 for a meeting left out for any other reason, 404 for one not on your calendar:
curl -X POST https://last30days.dev/api/v1/briefs/left-out -H "Authorization: Bearer sk_live_your_key_here" -H "Content-Type: application/json" -d '{"connection_id": "<connection_id>", "event_id": "<event_id>", "mode": "schedule"}'
Choose who a meeting researches before its research starts. person is {"kind": "auto"} to let briefs choose, {"kind": "attendee", "email": ...} for one of external_attendees, {"kind": "named", "name": ..., "company": ...} for someone else (no email address is sent anywhere), or {"kind": "none"}. include_person and include_company turn a report off; at least one must stay. Fields you leave out keep their value. Each external attendee has name, selectable (false when neither the invite nor a name you gave before names them), and suggested_name with suggestion_source (past_meetings or web), a name to confirm, never used on its own. To choose an attendee who is not selectable, send their name with the email: {"kind": "attendee", "email": ..., "name": ..., "company": ...}. The name is remembered for that address. 400 when the attendee is not an outside attendee on the meeting, or has no name:
curl -X PUT https://last30days.dev/api/v1/briefs/<id>/targets -H "Authorization: Bearer sk_live_your_key_here" -H "Content-Type: application/json" -d '{"person": {"kind": "named", "name": "Dana Whitfield", "company": "Whitfield Capital"}, "include_company": true}'
Muting was removed: skip a meeting instead. POST /api/v1/briefs/mutes and DELETE /api/v1/briefs/mutes/<id> answer 410.

Connections

See what is connected to your account: the AI apps you approved (name, host and connected_at), your Slack workspaces (status, and billing_disconnected when the person who installed it deleted their account) and the Slack accounts you linked, and your Google Calendar (google_email and status, or connected: false). Connecting and disconnecting are done on the site, under Account, Connections:

curl https://last30days.dev/api/v1/connections -H "Authorization: Bearer sk_live_your_key_here" # -> {"connected_apps":[{"name":"Claude","host":"claude.ai","connected_at":"..."}], # "slack":{"workspaces":[{"installation_id":"<id>","team_name":"Acme","status":"active","billing_disconnected":false}],"linked_identities":[{"team_name":"Acme"}]}, # "google_calendar":{"connected":true,"google_email":"you@acme.com","status":"active"}}