GRANSKA

Get an upload URL

POSThttps://api.granska.cloud/v1/upload-url

Issues a job id and a pre-signed URL to upload a PDF to: the one way to give POST /v1/analyze its document.

Bearer tokenSpends no quota

The one way to hand over a document

This is how every document reaches an analysis. POST /v1/analyze used to take the document inline as pdfBase64 or as a pdfUrl for the gateway to fetch; both are retired and now answer 400 BAD_REQUEST.

They were retired because they made the analysis call carry the document. POST /v1/analyze is meant to answer in milliseconds; a document inside it turned a queue operation into a transfer, put a file-sized transfer inside a request with an HTTP timeout on it, and could not be retried after a timeout without starting, and paying for, a second analysis. A large document is exactly where that went wrong, and a large document is the normal case here.

The two-step flow moves the transfer off the API entirely. You ask for a URL, you PUT the file straight at cloud storage, and the analysis call then carries a job id and nothing else.

Request
curl -X POST https://api.granska.cloud/v1/upload-url \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "uploadAttestation": { "agreementVersion": "legal:2", "uploadAuthorised": true } }'

# Then PUT the document straight at the returned uploadUrl:
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: application/pdf" \
  --data-binary @utredning.pdf
Response
{
  "success": true,
  "jobId": "job_7d41c9",
  "uploadUrl": "https://storage.googleapis.com/uploads/job_7d41c9.pdf?X-Goog-Signature=..."
}

The two steps

  1. POST /v1/upload-url with uploadAttestation, your confirmation that you may submit this document under the current upload agreement. It answers with a jobId and an uploadUrl.
  2. PUT the file at uploadUrl with Content-Type: application/pdf. The content type is baked into the URL when it is issued, so sending a different one fails the upload.

Then call POST /v1/analyze with that jobId. It is the only document field the analysis takes.

The jobId is issued here and is the same id you poll on afterwards — an upload URL and its analysis share one identity from the beginning.

Finish the upload and start the analysis within 30 minutes of asking for the URL. After that, an upload session that no POST /v1/analyze has claimed is cancelled by the next cleanup run, which normally comes within 15 minutes. Once it is cancelled, the URL stops accepting bytes and anything already sent is discarded.

JOB=$(curl -s -X POST https://api.granska.cloud/v1/upload-url \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{ \"uploadAttestation\": { \"agreementVersion\": \"$AGREEMENT_VERSION\", \"uploadAuthorised\": true } }")

curl -X PUT "$(echo "$JOB" | jq -r .uploadUrl)" \
  -H "Content-Type: application/pdf" \
  --data-binary @utredning.pdf

The upload agreement

Every document is submitted under the upload agreement that GET /v1/legal-agreement describes, and this is the call that checks it, before any upload URL exists. Read agreementVersion there and send it back here as uploadAttestation, { "agreementVersion": "<that version>", "uploadAuthorised": true }, once the person or system you act for has confirmed that they may submit this document.

Once the agreement is enforced, your organisation must also have accepted the current version. A real administrator of your organisation accepts it in the GRANSKA app; no API call can, and your API key relies on that acceptance rather than holding one of its own. Until then this call answers 409 LEGAL_AGREEMENT_REQUIRED, and error.details.missing names ORGANISATION_ACCEPTANCE. A missing uploadAttestation is named there as UPLOAD_ATTESTATION, a confirmation of an earlier version is 409 STALE_AGREEMENT_VERSION, and uploadAuthorised: false is a 400.

The confirmation travels with the upload: the jobId this call returns carries it into POST /v1/analyze, which needs no second one. That jobId belongs to the API key that asked for it; analysing it with another key is 409 UPLOAD_NOT_ADMITTED.

The Origin header, if a browser does the upload

The upload URL is a resumable-upload session, and cloud storage takes the allowed origin from the call that created the session — this one — rather than from the bucket's CORS configuration.

So if your server fetches the URL and hands it to a browser on a different origin, the browser's PUT fails CORS even though the URL is valid. Send the browser's origin as the Origin header on this call and it is baked into the session correctly.

A server-to-server upload never meets this: with no browser involved there is no CORS check.

ParameterDescription
Origin
string·header
The browser origin that will perform the upload. Baked into the returned URL as the only origin allowed to complete it.GCS's resumable-upload protocol takes the origin from this call, not from the bucket's CORS rules — so a server that fetches the URL and hands it to a browser on another origin gets a CORS failure on the upload.
uploadAttestation
{ agreementVersion: string, uploadAuthorised: true }·body
Your confirmation, for this one document, that you are authorised to submit it under the current upload agreement. Send it only once the person or system you act for has confirmed it. Required once the agreement is enforced, together with your organisation's acceptance, which an administrator gives in the GRANSKA app.

Errors

This endpoint spends no quota. Beyond your token, it checks only the upload agreement, so its other refusals are the agreement's.

ErrorWhen
400
BAD_REQUEST
uploadAttestation is not { agreementVersion, uploadAuthorised: true } naming a published version of the agreement — uploadAuthorised: false included — and details.reason is INVALID_ATTESTATION. Not probed: it is answered only for a credential whose workspace exists.
401
UNAUTHORIZED
The Authorization header is missing, is not a readable bearer token, or names no tenant.
401
TOKEN_EXPIRED
The access token was issued by this gateway and has since expired. Not probed: it needs a token older than its own lifetime.
403
FORBIDDEN
The workspace has no configuration, or the upload agreement is enforced and the API client belongs to a workspace that is not a customer organisation, so nobody can accept for it, and details.reason is INTEGRATION_OUTSIDE_ORGANISATION. Not probed: it needs such a client.
409
LEGAL_AGREEMENT_REQUIRED
The upload agreement is enforced and this document does not meet it: the organisation has not accepted the current version, or the request carries no uploadAttestation. details.missing names which; an administrator accepts for the organisation in the GRANSKA app, never through this API. Refused before an upload URL is issued, a document is fetched or a run is spent. Not probed: it needs the agreement enforced.
409
STALE_AGREEMENT_VERSION
uploadAttestation names an earlier published version of the agreement. details.currentVersion is the one to read and attest. Not probed: it needs a second published version.
500
INTERNAL_ERROR
An unexpected server-side failure. Not probable from outside — reaching it means something is wrong.
503
SERVICE_UNAVAILABLE
The upload agreement could not be checked; details.reason is AGREEMENT_UNAVAILABLE and a Retry-After header says when to try again. Never an answer about acceptance. Not probed: it needs an injected infrastructure failure.

Send it without writing a client

If your organisation already has an account, an administrator can request an upload URL from the API tester at /admin/api-tester and see the response before writing any code.