The REST API lets a script, a workflow tool or a backend work in one MetricPeek team over plain HTTPS. It covers the same work as the MCP server that AI assistants use: reading reports, posts, media and hashtag lists, writing drafts and lists, publishing, and the few actions that spend credits. Every endpoint runs the same operation as the matching MCP tool, with the same checks, the same prices and the same limits, so a team gets one set of rules whichever door a call comes through. This page covers access, authentication, the shape of requests and answers, limits, safe retries, background jobs and file uploads. Every error code is explained on REST API Errors, and every endpoint with its arguments on the API reference.
Before you start
The REST API is switched on by the MetricPeek operator. Until it is open, every call with a valid key is refused with rest_disabled, and the same key keeps working on the MCP server.
Four things have to be in place:
- A plan with API keys. API keys start on the Start plan and are not part of Free. The pricing page lists the plans that include them.
- The team owner. Only the owner of a team can create its API keys, and a key acts as that owner.
- API & MCP access switched on for the team. The owner turns it on in Settings → API & MCP, on the API & MCP access card. The same switch covers assistants and API keys.
- An API key. The owner creates it in Settings → API & MCP with the app switched to the team the key is for, as Create a key describes. The key is shown only once.
A key works in the team it was created in and nowhere else. Its access level, Read only, Drafts or Full, decides which endpoints it may call, as What a key may do shows.
Make the first call
The examples on this page keep the key and the address in environment variables, so neither is typed into a command or stored in a script:
METRICPEEK_API_KEYholds the API key.METRICPEEK_API_URLholds the address of MetricPeek followed by/api/v1.
This call asks which team the key works in and what it may do there:
curl "$METRICPEEK_API_URL/me" \
-H "Authorization: Bearer $METRICPEEK_API_KEY"
The answer names the person the key acts for, the team, the four capabilities the key actually has, the team's timezone and its content language. A key that answers here is set up correctly.
Authentication
Every request carries the key in the Authorization header, after the word Bearer and a space. Nothing else authenticates a call: a key in the address or in the body is refused as if it were missing, and so is a sign-in token of an AI assistant.
Every failure to authenticate gets the same answer, 401 with the code unauthorized, whether the header is missing, the key is mistyped, revoked or expired. The answer never tells a stranger which keys exist. The same wrong key repeated many times within a minute is slowed down with rate_limited.
A key that authenticates but may not act for its team right now gets 403, and the code field names the reason, such as mcp_disabled_for_team or plan_lapsed. These reasons are only ever shown after a valid key.
Endpoints at a glance
Every path below starts with /api/v1. The API reference lists each endpoint with its arguments and its answer, and the same contract is published as an OpenAPI specification at /api/v1/openapi.json.
| Area | Endpoints | Level |
|---|---|---|
| Account | GET /me, GET /credits, GET /limits, GET /jobs/{job_id} |
Read only |
| Reports | GET /reports, GET /reports/{type}/{id} |
Read only |
| Reports, paid | POST /reports, POST /reports/{type}/{id}/refresh, PUT /reports/{type}/{id}/tracking |
Full |
| Hashtags | GET /hashtag-lists, GET /hashtag-lists/{list_id} |
Read only |
| Hashtags | POST /hashtag-lists, POST /hashtag-lists/{list_id}/hashtags |
Drafts |
| Hashtags, paid | POST /hashtags/search |
Full |
| Posts | GET /posts, GET /posts/{id}, GET /posts/{post_id}/validation |
Read only |
| Posts | POST /posts, PATCH /posts/{post_id}, POST /posts/{post_id}/cancel |
Drafts |
| Posts | POST /posts/{post_id}/schedule, POST /posts/{post_id}/publish, POST /posts/{post_id}/retry |
Full |
| Media | GET /media |
Read only |
| Media | POST /media/imports, POST /uploads |
Drafts |
| Media, paid | POST /profile-media-downloads |
Full |
| Brand voices | GET /brand-voices, GET /brand-voices/{id} |
Read only |
| Social accounts | GET /social-accounts, GET /social-accounts/{account_id}/analytics, GET /connect-link |
Read only |
PATCH /posts/{post_id} on a post that is already scheduled needs Full, because changing it changes what gets published.
Sending a request
- Reads take their arguments in the query string. A
GETwith a body is refused. A list in a query string is written as values separated by commas, andtrueandfalsestand for yes and no. - Writes take their arguments in a JSON object in the body, sent with
Content-Type: application/json. A write with a query string is refused, so that its arguments stay out of server logs. - Only the arguments an endpoint declares are accepted. An unknown name is refused with
validation_failedrather than quietly ignored, and so is an argument that is already part of the path and is sent again in the query string or in the body. A name repeated in the query string counts once, with its last value. A misspelt filter therefore never returns the unfiltered list. - Times are ISO 8601. A time without a UTC offset is read in the team's timezone. A time the clocks skip when they jump forward is refused rather than shifted, and a time that occurs twice when they go back is read as the earlier one.
- Identifiers in a path are the ones the API itself returned. An item of another team answers exactly like an item that does not exist, with
not_found.
This read lists the team's Instagram profile reports, ten at a time:
curl "$METRICPEEK_API_URL/reports?type=profile&platform=instagram&limit=10" \
-H "Authorization: Bearer $METRICPEEK_API_KEY"
Pages. A list that has more items than one page carries page.has_more set to true and a page.cursor, and the answer also has a Link header with rel="next" pointing at the next page. The next page is asked for with the same query plus cursor. The optional limit sets the page size, and the reference gives its range.
Reading the results
A successful answer is JSON, in the same envelope the MCP server returns:
| Field | What it holds |
|---|---|
data |
The answer itself: the report, the list of posts, the new draft, the job. |
meta |
A link to the same item in the app, and the time of the answer in the team's timezone and in UTC. |
page |
Present on lists with more to come: the cursor of the next page. |
billing |
Present when a call could cost credits: what it charged and, when the key's role may see it, the balance left. |
untrusted_content |
Text written by the accounts being analysed, such as biographies, captions and comments. |
notes |
Short sentences about the answer, for example that an older request was replaced. |
Fields with nothing in them are left out rather than sent empty, so a missing page means the list is complete and a missing billing means the call cost nothing.
Untrusted content stays apart. Biographies, captions and comments of the accounts being analysed never appear inside data. They come in untrusted_content, each with where it came from, and data points at them. A script that passes them on to a language model treats them as material being analysed, never as instructions.
Status codes. A read, and a write that changes something in place, answers 200. A write that creates something answers 201 with a Location header pointing at what it created. A write that starts a background job answers 202 with a Location header pointing at the job. Every answer of an endpoint, and every error, carries Cache-Control: no-store, because it holds one team's data. The public OpenAPI specification is the one exception and may be cached.
Calls made with a key appear in the Activity card of Settings → API & MCP, in the team the key belongs to, next to the calls of the team's assistants.
Errors
Every refusal is application/problem+json, in one shape:
{
"type": "<address of this code on /docs/api-errors>",
"title": "The team does not have enough credits for this action.",
"status": 402,
"detail": "The team does not have enough credits for this action.",
"code": "insufficient_credits"
}
code is the part a script branches on, and it never changes within v1. title and detail are sentences for a person and may be reworded. An optional context object carries identifiers such as the reason of a refusal or retry_after_seconds, and never a value that was sent. Every code, its status and what to do about it are on REST API Errors.
What a key may do
A key is created at one of three access levels, and each includes everything in the one before it. The owner can change the level later with Change access in the key's ⋮ menu.
| Level | Capabilities | What it opens |
|---|---|---|
| Read only | read | Every GET endpoint |
| Drafts | read, write | Also new and changed drafts, hashtag lists, media imports and upload links, and taking a post off the schedule |
| Full | read, write, publish, spend | Also scheduling, publishing and retrying posts, changing a scheduled post, and the paid endpoints within the key's daily credit limit |
A call beyond the key's level is refused before anything is read or charged, with write_not_allowed, publish_not_allowed or spend_not_allowed. GET /me returns the capabilities a key actually has, so a script can check before it starts.
Limits
The REST API has no limits of its own. A key shares every limit with the MCP server, so its calls over both count together, and its team's assistants draw on the same team allowances.
- Per minute. Each key may make only so many requests a minute, and each connection only so many calls a minute, with a tighter count for calls that reach out to the social networks.
- Background jobs. A team can have only a few background jobs running at once.
- Free calls a day. Calls that charge nothing draw on the team's daily allowance of free calls. It is smallest on Free and grows with each plan, as the pricing page shows.
- Credits a day. Paid calls are limited by the team's balance, the team's daily credit limit for assistants and keys, and the key's own daily credit limit.
A call over a limit is refused with 429 and a Retry-After header giving the number of seconds to wait. The daily limits start again at midnight in the team's timezone, and their Retry-After counts down to it. GET /credits shows the balance, when the key's role may see it, the credits used today and the daily credit limits that apply, and GET /limits shows the limits of the team's plan. How the credit limits are worked out is under Credits and limits.
Repeating a request safely
A network can drop the answer to a request that did its work. The Idempotency-Key header makes a repeat safe: a value the script picks for one request and sends again, unchanged, with every retry of that same request.
- Paid endpoints require it. A call to
POST /hashtags/search,POST /reports,POST /reports/{type}/{id}/refresh,PUT /reports/{type}/{id}/trackingorPOST /profile-media-downloadswithout the header is refused withvalidation_failed, reasonidempotency_key_required, before any work or charge. - Every other write accepts it, publishing included. A
GETrefuses the header rather than ignore it, because a read has nothing to protect. - A repeat returns the first answer. The same request sent again with the same key within 24 hours returns the first call's result, does not do the work a second time and does not charge a second time. Such an answer carries the header
Idempotent-Replayed: true. - A key belongs to one request. On a write without money, the same key with different arguments is refused with
idempotency_key_reused, and nothing is done. On a paid endpoint, the same key with a different request is simply a new request and is charged as one. A paid call also getsidempotency_key_reused, with the reasoncharge_key_spent, when its key already paid for an earlier call whose result is no longer kept. A new request always gets a new key. - A repeat that arrives too early waits. While the first call with a key is still running, a repeat of a write can be refused with
idempotency_key_in_progressand aRetry-After, and a later repeat returns the finished result. - A key counts for one API key only. An
Idempotency-Keyis remembered separately for each API key, so after a key is replaced by a new one, a paid request repeated with the sameIdempotency-Keyis not recognised as a repeat and is charged again. Before replacing a key, orders still in progress are finished or checked withGET /jobs/{job_id}, and after the switch an old order is never retried blindly with the new key.
A new random value per request works well, generated once and kept with the request until it succeeds:
IDEMPOTENCY_KEY=$(uuidgen)
curl -X POST "$METRICPEEK_API_URL/hashtags/search" \
-H "Authorization: Bearer $METRICPEEK_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-d '{"query":"sourdough","platform":"instagram"}'
Background jobs
Some work takes longer than one request: a new report, a hashtag search, a media import, a profile media download, publishing. Those endpoints answer 202 at once, with the job in data.job_id and a Location header pointing at GET /api/v1/jobs/{job_id}. A job id has the form kind:id, for example a hashtag_search: id for a search.
curl "$METRICPEEK_API_URL/jobs/$JOB_ID" \
-H "Authorization: Bearer $METRICPEEK_API_KEY"
data.state is pending, running, succeeded, partially_succeeded or failed, and data.finished turns true once the job has ended. A finished job names what it produced, such as the report, the new media item or the hashtags found. The same 202 comes back when the work was already done, for example a report the team already had, so a script always follows Location until finished is true. Asking every few seconds is enough, and a tighter loop only uses up the per-minute limit.
Uploading files
A file from the script's own disk reaches the Media Library in two steps. A file at a public https address takes one step instead: POST /media/imports downloads it in the background.
- Ask for upload links.
POST /uploadsdescribes each file by its SHA-256 hash and its size in bytes, with an optional name and type. The answer is201and carries, underdata.upload.uploads, one entry per file: itsurl, thePUTmethod and, underheaders, anAuthorizationvalue holding a one-time upload secret. - Send the bytes. Each file goes to its own
urlwithPUT, thatAuthorizationvalue, the file's ownContent-Typeorapplication/octet-stream, and exactly the bytes that were described.
curl -X POST "$METRICPEEK_API_URL/uploads" \
-H "Authorization: Bearer $METRICPEEK_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-d '{"files":[{"sha256":"<sha256 of the file>","size_bytes":<size in bytes>,"file_name":"cover.jpg"}]}'
curl -X PUT "<url of the file's entry>" \
-H "Authorization: <Authorization value of the file's entry>" \
-H "Content-Type: image/jpeg" \
--data-binary @cover.jpg
- The upload secret appears in that one answer only. It is never stored in readable form and never shown again. A repeat of the same
POST /uploadswith the sameIdempotency-Keyreturns the same batch with new secrets, and the old ones stop working. - Each link takes exactly the described file, once, until the expiry time in the answer. Bytes that do not match the description are refused.
- The batch is a job. The
Locationheader of the201points at the batch's job, andGET /jobs/{job_id}reports each file: its new media id, still waiting, or refused with the reason. - The accepted file types and size limits are stated in the answer. Uploads use the team's storage allowance, not credits, and a file already in the Media Library is returned as it is rather than stored twice.
What this does not do
- It does not accept anything but a team API key. Sign-in tokens of AI assistants, cookies and keys in the address are refused.
- It does not work across teams. A key serves the team it was created in.
- It does not delete anything. Reports, media, posts and drafts stay, and a cancelled scheduled post becomes a draft.
- It does not buy credits, change the plan, or connect and disconnect social accounts.
GET /connect-linkreturns a link the key's owner opens in the app to connect or reconnect an account. - It does not skip client approval or the network rules. A post waiting for the client, or one that fails validation, is refused.
- It is not meant for code running in a web browser. It sends no permission for other websites to call it, and a key inside a web page is a leaked key.
When something looks wrong
Every call gets 401. The key is missing from the Authorization header, mistyped, revoked or expired. An environment variable that is empty sends no key at all, and test -n "$METRICPEEK_API_KEY" && echo set checks it without printing the key. The API keys card of the key's team shows whether the key is still Active.
Every call with a valid key gets 403 with rest_disabled. The REST API is not open yet or is switched off for a while. Nothing about the key needs changing, and it keeps working on the MCP server.
A paid call gets 422 with idempotency_key_required. Paid endpoints need an Idempotency-Key header, as Repeating a request safely describes.
A call gets 403 with spend_not_allowed or publish_not_allowed. The key is below Full. The owner raises it with Change access in the key's ⋮ menu, in Settings → API & MCP of the key's team.
A call gets 429. A limit was reached, and Retry-After says how long to wait. A daily limit waits for midnight in the team's timezone, while the per-minute ones clear within a minute. For most limits, the scope in context names which one it was.
A call gets 422 with validation_failed. The reason in context says what was wrong: unknown_argument for a name the endpoint does not take, arguments_in_query for a write that sent its arguments in the query string rather than in the JSON body, and body_not_allowed for a GET with a body.
A call gets 500 with internal_error. Something failed on MetricPeek's side, and part of the call may already have taken effect. The result is checked first, then the call is repeated with the same Idempotency-Key it was first sent with.
Anything else, or the same problem twice, is worth sending to support with the endpoint, the code and roughly when it happened, never with the key.