Get an upload URL
https://api.granska.cloud/v1/upload-urlIssues a job id and a pre-signed URL to upload a PDF to: the one way to give POST /v1/analyze its document.
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.
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{
"success": true,
"jobId": "job_7d41c9",
"uploadUrl": "https://storage.googleapis.com/uploads/job_7d41c9.pdf?X-Goog-Signature=..."
}The two steps
POST /v1/upload-urlwithuploadAttestation, your confirmation that you may submit this document under the current upload agreement. It answers with ajobIdand anuploadUrl.PUTthe file atuploadUrlwithContent-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.
| Parameter | Description |
|---|---|
Originstring·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.
| Error | When |
|---|---|
400BAD_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. |
401UNAUTHORIZED | The Authorization header is missing, is not a readable bearer token, or names no tenant. |
401TOKEN_EXPIRED | The access token was issued by this gateway and has since expired. Not probed: it needs a token older than its own lifetime. |
403FORBIDDEN | 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. |
409LEGAL_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. |
409STALE_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. |
500INTERNAL_ERROR | An unexpected server-side failure. Not probable from outside — reaching it means something is wrong. |
503SERVICE_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.