1. Authentication
Every request carries an API key as a bearer token. There is no OAuth flow, no session cookie, and no scopes. A key grants the whole of one account.
curl https://www.nudgehost.com/api/v1/files \
-H "Authorization: Bearer nh_sk_REPLACE_WITH_YOUR_OWN_KEY"The key above is a placeholder rather than a working credential. Yours comes from the dashboard.
The key format
A key is 49 characters long. It opens with the fixed marker nh_sk_ and continues with 43 base64url characters carrying 256 bits of randomness. The marker is fixed so that a leaked key is recognisable on sight, and so that credential scanners have a pattern to match.
The scheme name is matched case-insensitively, so Bearer and bearer both work, and any run of spaces or tabs may separate the scheme from the key. The key itself must contain no whitespace.
Generating a key
Keys are created in the API keys panel of your dashboard. Sign in first. A signed-out visitor is redirected to the sign-in page and the link's fragment is lost on the way, so you would arrive at the top of the dashboard rather than at the panel. Give each key a name of up to 60 characters so you can tell them apart later, then create it.
The key is displayed once, at the moment it is created. What NudgeHost stores is a SHA-256 hash of it, so the original string cannot be recovered afterwards by you or by us. Copy it somewhere safe before you close the panel. If a key is lost, revoke it and create another.
After that, the dashboard shows the first 12 characters of each key so you can tell two of them apart, and holds back the remaining 37.
How many keys a plan holds
Every plan can use the API, including the free one. What varies is how many keys an account may hold at once.
- Free: 2 keys
- Pro: 10 keys
- Studio: 30 keys
Revoking a key frees its slot straight away. Full plan details are on the pricing page.
2. The host rule
Send every request to www.nudgehost.com. That host is canonical and it is where the API answers.
The apex, nudgehost.com, answers 308 and points at the www host. That redirect crosses an origin boundary, so HTTP clients drop the Authorization header while following it. The request then arrives with no credential at all and is refused with 401 and the code unauthorized, which reads exactly like a bad key. If a key you know is good keeps coming back unauthorized, check the host before you rotate anything.
# Wrong host. The apex answers 308 and redirects to www, and the
# Authorization header does not survive that hop, so this arrives
# with no credential and comes back 401 unauthorized.
curl -L https://nudgehost.com/api/v1/files \
-H "Authorization: Bearer nh_sk_REPLACE_WITH_YOUR_OWN_KEY"
# Right host. Send every request straight to www.
curl https://www.nudgehost.com/api/v1/files \
-H "Authorization: Bearer nh_sk_REPLACE_WITH_YOUR_OWN_KEY"A client that does not follow redirects sees the 308 itself rather than the 401. Either way the call does not do what you asked, and pointing at www is the whole fix.
3. Rate limits
Each key gets a fixed number of requests per 60 second window, set by the account's plan.
- Free: 20 requests per 60 seconds
- Pro: 60 requests per 60 seconds
- Studio: 150 requests per 60 seconds
The budget belongs to the key rather than to the account or to the calling IP address. Two keys on one account therefore have independent budgets, and one script exhausting its own key leaves the other key untouched. Splitting a noisy job onto its own key is a reasonable way to keep it away from everything else.
Going over returns 429 with the code rate_limited and a Retry-After header. That header carries the full window, 60 seconds, rather than a countdown to the moment a slot frees up. Treat it as an upper bound. Waiting it out always works, and a slot often opens sooner.
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Cache-Control: private, no-store
Content-Type: application/json
{
"error": {
"code": "rate_limited",
"message": "This key has made too many requests. Please slow down and try again shortly.",
"details": { "retryAfterSeconds": 60 }
}
}4. Errors
Every failure arrives in the same envelope, with a machine-readable code beside a sentence written for a person. The details object is present only where a client would otherwise have to read a number out of the sentence, such as the byte ceiling a file just exceeded.
HTTP/1.1 404 Not Found
Cache-Control: private, no-store
Content-Type: application/json
{
"error": {
"code": "not_found",
"message": "That file is gone or was never on your account."
}
}Switch on error.code rather than on the status or the wording. Several distinct failures share a status, and the wording is free to change. Each code maps to exactly one status, so the same code never arrives as a 400 from one endpoint and a 422 from another.
| Code | Status | Meaning |
|---|---|---|
| unauthorized | 401 | No Authorization header arrived, or it was not a bearer credential. |
| invalid_key | 401 | A key arrived and did not resolve. Malformed, unknown, and revoked all give this one answer, so the endpoint cannot be used to learn which. |
| rate_limited | 429 | This key has used up its window. Carries Retry-After. |
| invalid_request | 400 | A body field or query parameter is missing, malformed, or the wrong type. Where there is a single culprit, details.field names it. |
| not_found | 404 | No such resource for this caller. A file that never existed and a file belonging to somebody else give the same answer on purpose. |
| file_too_large | 413 | Over the plan's per-file ceiling. details carries maxBytes and plan. |
| link_limit_reached | 403 | The account is at its active-link cap. |
| upload_incomplete | 422 | The bytes never arrived in storage, so there is nothing to complete. |
| upload_rejected | 422 | The bytes arrived and cannot be used, either an unsafe archive or one that unpacks past the plan ceiling. The message gives the specific reason. |
| method_not_allowed | 405 | Right path, wrong verb. |
| internal_error | 500 | Our fault. Reported before the response is sent. |
That list of codes is closed. A new one would be a breaking change and will not appear without a version change, so a client may treat an unrecognised code as a bug rather than as something to handle.
The kind field on a file is the opposite. It is not a closed set, because the column behind it is unconstrained text. Treat it as a string and leave a path for a value you do not recognise rather than switching over it exhaustively. The three values the code produces today are file, docx, and site.
5. The REST endpoints
Four endpoints, all under /api/v1. Every id, slug and URL in the samples below is a placeholder. Yours come back in the responses.
Each path answers one verb. The others are wired to a refusal rather than left to fall through, so a POST to a listing endpoint returns 405 with the code method_not_allowed and the message That method is not supported on this endpoint. A 404 from one of these paths always means the resource, never the verb.
POST /api/v1/uploads
Reserves a link and returns a signed URL to send the bytes to. This creates the row. Nothing serves until you complete the upload in the next call.
The JSON body takes four fields.
filename(required), the name to store the file under, including its extension.contentType(required), the MIME type of the bytes.fileSize(required), the exact byte count you are about to send, as a number.desiredSlug(optional), a preferred short name for the link. It is cleaned up and checked on the server. One that is taken or unusable is replaced with a random one rather than failing the call, so read the slug back out of the response.
curl -X POST https://www.nudgehost.com/api/v1/uploads \
-H "Authorization: Bearer nh_sk_REPLACE_WITH_YOUR_OWN_KEY" \
-H "Content-Type: application/json" \
-d '{
"filename": "report.pdf",
"contentType": "application/pdf",
"fileSize": 182734
}'HTTP/1.1 201 Created
{
"id": "00000000-0000-0000-0000-000000000000",
"slug": "example1",
"url": "https://www.nudgehost.com/f/example1",
"upload": {
"url": "https://EXAMPLE-BUCKET.r2.cloudflarestorage.com/files/EXAMPLE/report.pdf?X-Amz-Signature=EXAMPLE_NOT_A_REAL_SIGNATURE",
"method": "PUT",
"expiresInSeconds": 300,
"headers": {
"Content-Type": "application/pdf",
"Content-Length": "182734"
}
}
}Answers 201 on success. It can also return 400 invalid_request, 401, 403 link_limit_reached, 413 file_too_large, 429, and 500. The 403 reaches free accounts at their active-link cap, and the 413 arrives when fileSize is over the plan ceiling, with details.maxBytes naming the limit.
POST /api/v1/uploads/{id}/complete
Turns the reserved row into a working link, once the bytes have landed. Takes no body. The id is the one from the start call.
curl -X POST \
https://www.nudgehost.com/api/v1/uploads/00000000-0000-0000-0000-000000000000/complete \
-H "Authorization: Bearer nh_sk_REPLACE_WITH_YOUR_OWN_KEY"HTTP/1.1 200 OK
{
"id": "00000000-0000-0000-0000-000000000000",
"slug": "example1",
"url": "https://www.nudgehost.com/f/example1",
"kind": "file",
"filename": "report.pdf",
"mimeType": "application/pdf",
"fileSize": 182734,
"createdAt": "2026-08-17T17:23:11.572Z"
}Answers 200 on success. It can also return 401, 404, 422, 429, and 500. The 422 covers two cases. The code upload_incomplete means the bytes never arrived, so PUT them and complete again. The code upload_rejected means the bytes arrived and cannot be used, with the specific reason in the message.
Read the url out of this response rather than reusing the one from step 1. A ZIP that unpacks into a site is served from its own address, so the final URL can differ from the one the start call predicted.
GET /api/v1/files
Lists the account's files, newest first. Takes three optional query parameters.
limit, a whole number from 1 to 100. Defaults to 50.cursor, thenextCursorfrom the previous page.includeDeleted, set to the stringtrueto include soft-deleted files.
curl "https://www.nudgehost.com/api/v1/files?limit=25" \
-H "Authorization: Bearer nh_sk_REPLACE_WITH_YOUR_OWN_KEY"HTTP/1.1 200 OK
{
"data": [
{
"id": "00000000-0000-0000-0000-000000000000",
"slug": "example1",
"filename": "report.pdf",
"fileSize": 182734,
"mimeType": "application/pdf",
"kind": "file",
"viewCount": 2,
"createdAt": "2026-08-17T17:23:11.572Z",
"expiresAt": null,
"url": "https://www.nudgehost.com/f/example1",
"hasPassword": false,
"isDeleted": false
}
],
"nextCursor": "EXAMPLE_OPAQUE_CURSOR_VALUE"
}Answers 200 on success, and can also return 400 invalid_request, 401, 429, and 500.
DELETE /api/v1/files/{id}
Deletes one file. The delete is soft, so the row survives and the slug stays taken, while the link stops serving.
curl -X DELETE \
https://www.nudgehost.com/api/v1/files/00000000-0000-0000-0000-000000000000 \
-H "Authorization: Bearer nh_sk_REPLACE_WITH_YOUR_OWN_KEY"HTTP/1.1 200 OK
{
"id": "00000000-0000-0000-0000-000000000000",
"deleted": true
}Answers 200 on success, and can also return 401, 404, 429, and 500. This call is not idempotent. Deleting the same id twice returns 404 the second time, and a client retrying after a timeout on a call that actually succeeded cannot tell that answer apart from a file that never existed.
6. The upload flow end to end
Publishing a file takes three calls. The link does not serve anything until all three have happened.
Step 1. Start the upload
POST the filename, content type and exact byte count to /api/v1/uploads. The response carries the file id you will need in step 3, and an upload object holding the URL to send the bytes to.
That URL is signed and it expires. You have 300 seconds to use it, which the response repeats as expiresInSeconds. If it lapses before the bytes land, start again for a fresh URL rather than reusing the id.
Step 2. PUT the bytes
# The URL, the method and both header values come from the
# upload object in step 1. Send them as given.
curl -X PUT "https://EXAMPLE-BUCKET.r2.cloudflarestorage.com/files/EXAMPLE/report.pdf?X-Amz-Signature=EXAMPLE_NOT_A_REAL_SIGNATURE" \
-H "Content-Type: application/pdf" \
--data-binary @report.pdfThe signature covers the byte count and the host. A body whose length is anything other than the fileSize you declared is refused by storage rather than by us.
Content-Type is not covered by that signature, and this catches people out. Storage accepts a PUT whose Content-Type disagrees with what you declared, so nothing fails at this step. What gets stored on the row is the contentType from step 1, and that value decides how the link serves the file afterwards. Nothing inspects the bytes to settle the disagreement. Send the same type in both places, and if the two ever differ, the one from step 1 is the one that matters.
Step 3. Complete the upload
curl -X POST \
https://www.nudgehost.com/api/v1/uploads/00000000-0000-0000-0000-000000000000/complete \
-H "Authorization: Bearer nh_sk_REPLACE_WITH_YOUR_OWN_KEY"Completing is idempotent. Calling it a second time on a file that is already complete returns the same 200 and the same body rather than an error, and destroys nothing, so a retry after a dropped connection is safe.
7. Pagination
The listing endpoint pages with a cursor. Ask for a page, and when more files exist the response carries a nextCursor to send back for the one after it. On the last page nextCursor is null.
limit takes a whole number from 1 to 100, and leaving it out gives you 50. Anything outside those bounds returns 400 with the code invalid_request and details.field set to limit.
The cursor is opaque. Pass back exactly the value you were given rather than one you build, because only values this API issued are accepted. A cursor that does not decode returns 400 with the code invalid_request and details.field set to cursor, with the message cursor is not one we issued. Send back the nextCursor from the previous page.
cursor=""
while :; do
url="https://www.nudgehost.com/api/v1/files?limit=100"
if [ -n "$cursor" ]; then
url="$url&cursor=$cursor"
fi
page=$(curl -s "$url" -H "Authorization: Bearer nh_sk_REPLACE_WITH_YOUR_OWN_KEY")
echo "$page" | jq -r '.data[].url'
cursor=$(echo "$page" | jq -r '.nextCursor // empty')
# An absent or null nextCursor is the last page.
[ -z "$cursor" ] && break
doneThat loop uses jq to read fields out of each response. If you do not have it installed, what you get is a command-not-found error from your shell rather than anything from NudgeHost.
One trap worth knowing about. includeDeleted accepts the exact string true and nothing else. The values 1, TRUE and yes are all read as false, and none of them is an error, so a request carrying one comes back 200 with deleted files quietly missing.
8. The MCP server
This section is not covered by the OpenAPI document. That document describes the REST API and nothing else, so a client generated from it will not have any of the tools below. Read this section rather than the schema if you are wiring up an AI client.
Endpoint and transport
The server lives at POST https://www.nudgehost.com/api/mcp. It speaks revision 2026-07-28 of the Streamable HTTP transport and nothing earlier. POST is the only verb it answers. Anything else returns 405.
The transport is stateless. There is no initialize handshake, no session id, no standalone GET stream, and no resumability, so the server remembers nothing between two requests. Every reply is a single JSON object sent as application/json. The server never opens an event stream, and a client that sends no Accept header is not refused over it.
Because nothing is remembered, each request has to carry its own context. Three headers matter.
MCP-Protocol-Versionon every request, matching the version inside the body.Mcp-Methodon every request, matching the body'smethod.Mcp-Nameontools/call, matchingparams.name.
The body repeats two of those facts under params._meta, which must carry io.modelcontextprotocol/protocolVersion as a string and io.modelcontextprotocol/clientCapabilities as an object. An empty object is fine. A header that is missing or that disagrees with the body returns 400, since a mismatch between what a gateway routes on and what the server executes is the shape of an attack rather than a typo worth guessing at.
curl -X POST https://www.nudgehost.com/api/mcp \
-H "Authorization: Bearer nh_sk_REPLACE_WITH_YOUR_OWN_KEY" \
-H "Content-Type: application/json" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: tools/call" \
-H "Mcp-Name: list_links" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "list_links",
"arguments": { "limit": 10 },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}'Authentication, host, and rate limits
Authentication is the same bearer key as the REST API, described in section 1. The host rule in section 2 applies here too, so send requests to www rather than to the apex.
The rate limit is the same per-key budget from section 3, and the two interfaces share it rather than getting one each. A key that has spent its window on REST calls has nothing left for MCP calls in that window, and the reverse. Splitting the two onto separate keys gives them separate budgets.
The tools
Four tools, listed here in the order tools/list returns them. The schemas each tool publishes are advisory. Every handler validates its own input, so a call that satisfies the published schema can still be refused, and the message says why.
share_text
Publishes text as a file and returns the link, in one call. A filename ending in .html or .htm produces a page the recipient reads in the browser. Content is capped at 2MB, measured in UTF-8 bytes rather than in characters, so text with accents or emoji reaches the cap sooner than its length suggests.
filename(required), the name to give the file, including an extension.content(required), the text to publish. May be empty.
create_upload_url
Starts an upload for a file the server does not hold, and returns the URL to send the bytes to. This is the MCP form of step 1 in section 6, and the same rules apply, including the 300 second expiry and the Content-Type behaviour.
filename(required).content_type(required), the MIME type of the bytes.size_bytes(required), a whole number, the exact byte count.desired_slug(optional), a preferred short name. One that is taken or unusable is replaced rather than failing the call.
complete_upload
Finishes an upload that create_upload_url started, once the bytes have been sent. This is step 3 in section 6, with the same behaviour, including that calling it twice is safe.
id(required), the id create_upload_url returned.
list_links
Lists the account's links, newest first. Deleted links are never included. Both fields are optional, and a call with no arguments is the ordinary way to use it.
limit(optional), a whole number from 1 to 100. Leaving it out gives you 50.cursor(optional), the cursor from the end of a previous reply. Pass back exactly the value you were given.
This tool publishes its own bounds rather than sharing the REST endpoint's, and the two currently hold the same numbers. Treat section 7 as describing both.
Auth errors
A refused credential comes back as a JSON-RPC error rather than in the REST envelope, because an MCP client parses JSON-RPC and would read the other shape as a malformed response. Two codes cover it, both at HTTP 401.
-31001, no credential arrived. Send the key as a bearer token.-31002, a key arrived and did not resolve. Malformed, unknown, and revoked give this one answer.
Neither response echoes a request id, so match them to your request by the fact that they arrived rather than by the id you sent.