Skip to content

Type an option name like full_page, an error code, or a topic.

Signed links

Put screenshot links in a web page without handing out a usable key. Signing steps, expiring links, signed status and bulk calls, six languages.

This is a signed link. The last parameter, signature, is what makes it safe to show in public.

Request
curl "https://curlshot.com/api/v1/screenshot?access_key=YOUR_ACCESS_KEY\
&cache=true\
&format=webp\
&url=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FEiffel_Tower\
&viewport_width=1280\
&signature=b565e2249494a979e38a79a4c9556b316ff64f15df96febf44c69b9709a52213" \
  --output eiffel.webp

You get the screenshot, the same as with a normal request:

The top of the Wikipedia article about the Eiffel Tower
The result: the screenshot the signed link points to.

Change anything in that link, even one letter of the url, and the answer is an error:

Response, status 403
{
  "error_code": "signature_invalid",
  "error_message": "The request signature does not match.",
  "documentation_url": "https://curlshot.com/docs/errors#signature_invalid"
}

Say you want a screenshot inside a web page, with a plain <img> tag. The link has to contain your access key, and anyone can read the source of a web page.

Without a signature, a visitor could copy your key, swap the url for any site they like, and take screenshots on your account.

A signature stops that. It is a short code that you compute on your server from two things: the exact parameters of the link, and your secret key. We compute the same code on our side and compare.

The visitor sees the link but not the secret key. So they can use the link as it is, but they cannot change it.

#Your two keys

Every API key has two parts. You find both under API keys in your dashboard.

PartWhat it is for
Access keyIdentifies you. It goes in the request.
Secret keySigns links, and checks webhooks, the messages we send to your server when a background job ends. It never goes in a request.

Keep the secret key on your server. It must not appear in HTML, in JavaScript that runs in the browser, or in a mobile app.

#How the signature is made

The signature is an HMAC-SHA256. That is a standard recipe that mixes a text with a secret and gives back a fixed-length code. Every common language has it built in.

Here are the steps, with the link from the top of this page as the example.

#1. Collect the parameters

Take every parameter you want in the link, including access_key. Leave signature out, because it does not exist yet.

Parameters
url            = https://en.wikipedia.org/wiki/Eiffel_Tower
format         = webp
viewport_width = 1280
cache          = true
access_key     = YOUR_ACCESS_KEY

#2. Sort them

Sort by name, from a to z. If a name appears more than once, sort those entries by value.

#3. Encode names and values

Percent-encode each name and each value. Percent-encoding replaces characters that have a special meaning in a link with a % and a code. For example : becomes %3A and / becomes %2F.

Use the strict form, known as RFC 3986. Only letters, digits and -, _, ., ~ stay as they are. A space becomes %20, never +.

#4. Join them

Write each pair as name=value and join the pairs with &. The result is called the canonical string. Canonical means "the one agreed way to write it".

Canonical string
access_key=YOUR_ACCESS_KEY&cache=true&format=webp&url=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FEiffel_Tower&viewport_width=1280

#5. Compute the HMAC

Compute HMAC-SHA256 of the canonical string, with your secret key as the key. Write the result as lowercase hex, which is 64 characters made of 0-9 and a-f.

With the secret key YOUR_SECRET_KEY, the canonical string above gives:

Signature
b565e2249494a979e38a79a4c9556b316ff64f15df96febf44c69b9709a52213

Put the canonical string after the ?, then add &signature= and the code.

Signed link
https://curlshot.com/api/v1/screenshot?access_key=YOUR_ACCESS_KEY&cache=true&format=webp&url=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FEiffel_Tower&viewport_width=1280&signature=b565e2249494a979e38a79a4c9556b316ff64f15df96febf44c69b9709a52213

#Sign functions

Each sample builds the same link as the worked example. Copy the function, then pass in your real keys.

SECRET_KEY="YOUR_SECRET_KEY"

# In a shell script, write the canonical string by hand:
# names sorted from a to z, values already percent-encoded.
CANONICAL="access_key=YOUR_ACCESS_KEY&cache=true&format=webp&url=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FEiffel_Tower&viewport_width=1280"

# HMAC-SHA256 as lowercase hex. openssl prints a label first, so keep the last word.
SIGNATURE=$(printf '%s' "$CANONICAL" | openssl dgst -sha256 -hmac "$SECRET_KEY" -hex | awk '{print $NF}')

curl "https://curlshot.com/api/v1/screenshot?$CANONICAL&signature=$SIGNATURE" --output eiffel.webp

Build the link on your server while you render the page, then print it into the HTML.

Your page
<img
  src="https://curlshot.com/api/v1/screenshot?access_key=YOUR_ACCESS_KEY&amp;cache=true&amp;format=webp&amp;url=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FEiffel_Tower&amp;viewport_width=1280&amp;signature=b565e2249494a979e38a79a4c9556b316ff64f15df96febf44c69b9709a52213"
  alt="Preview of the Eiffel Tower article"
  width="640"
  height="512"
/>

Inside HTML, write each & as &amp;. The browser turns it back into & before it asks for the screenshot.

Always put cache=true in a link that visitors load. The first visitor triggers the render. Everyone after that gets the stored copy, which is free. See Caching.

A signature proves that a link was not changed. On its own it does not make the link expire: the link keeps working until you delete the API key it was made with.

To set an end date, add expires before you sign.

#expires

The moment the link stops working, as Unix time in seconds. Unix time is the number of seconds since 1 January 1970, the way most languages count time.

There is no default. Leave it out and the link has no end date.

This sample makes a link that works for one hour:

Request
ACCESS_KEY="YOUR_ACCESS_KEY"
SECRET_KEY="YOUR_SECRET_KEY"

# One hour from now, in seconds.
EXPIRES=$(( $(date +%s) + 3600 ))

# Names sorted from a to z: access_key, expires, url.
CANONICAL="access_key=$ACCESS_KEY&expires=$EXPIRES&url=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FEiffel_Tower"
SIGNATURE=$(printf '%s' "$CANONICAL" | openssl dgst -sha256 -hmac "$SECRET_KEY" -hex | awk '{print $NF}')

curl "https://curlshot.com/api/v1/screenshot?$CANONICAL&signature=$SIGNATURE" --output eiffel.png

You get the screenshot for the next hour. After that, the same link answers:

Response, status 403
{
  "error_code": "request_expired",
  "error_message": "This request has expired: its `expires` time is in the past. Create a new link with a later `expires`.",
  "documentation_url": "https://curlshot.com/docs/errors#request_expired"
}

An expired link has its own code, request_expired, so your code can tell "make a new link" apart from "the signature is wrong".

A value that cannot be read as a time is a different problem. expires=tomorrow, a date text, a decimal number, or the same parameter twice is answered with status 400:

Response, status 400
{
  "error_code": "invalid_options",
  "error_message": "expires: must be a Unix time in whole seconds, such as 1767225600",
  "documentation_url": "https://curlshot.com/docs/errors#invalid_options",
  "errors": [
    { "field": "expires", "message": "must be a Unix time in whole seconds, such as 1767225600" }
  ]
}

Because expires is part of the signed text, a visitor cannot push the date forward. Changing it breaks the signature.

In the sign functions above, expires is one more parameter. In Node.js, for example, add expires: Math.floor(Date.now() / 1000) + 3600 to the list.

#Accept only signed requests

By default, a key accepts both signed and unsigned requests. That is fine while you test.

For a key whose links are shown in public, turn on Require signature for that key in your dashboard. From then on, a request with that key and no signature is refused:

Response, status 403
{
  "error_code": "signature_required",
  "error_message": "This access key only accepts signed requests. Add a `signature` parameter.",
  "documentation_url": "https://curlshot.com/docs/errors#signature_required"
}

Without the setting, someone could delete the signature from your link and send their own parameters with your access key.

Once the setting is on, every call with that key needs a signature. That includes the status calls and bulk calls in the next section.

#One signature, one endpoint

An endpoint is one address of the API, such as /screenshot or /usage. A signature is only good for the endpoint it was made for.

So a signed screenshot link is not a pass for anything else. Someone who copies it from your page cannot use it to read your usage, your jobs or your stored files, and cannot turn it into a bulk call.

EndpointWhat you sign
/screenshotEvery parameter of the request.
/usage, /jobs/:id, /batches/:id, /files/:idaccess_key, and expires if you want an end date. Nothing else.
/bulkaccess_key and body_sha256, and expires if you want one. Nothing else.

Send a signed screenshot link to one of the other endpoints and you get:

Response, status 403
{
  "error_code": "signature_invalid",
  "error_message": "This signature was made for another endpoint. Sign a query that holds only `access_key` (and `expires`) for this one.",
  "documentation_url": "https://curlshot.com/docs/errors#signature_invalid"
}

#Sign a status call

The status calls are GET /usage, GET /jobs/:id and GET /batches/:id. They take no options, so the signed text is short:

Request
ACCESS_KEY="YOUR_ACCESS_KEY"
SECRET_KEY="YOUR_SECRET_KEY"

CANONICAL="access_key=$ACCESS_KEY"
SIGNATURE=$(printf '%s' "$CANONICAL" | openssl dgst -sha256 -hmac "$SECRET_KEY" -hex | awk '{print $NF}')

curl "https://curlshot.com/api/v1/usage?$CANONICAL&signature=$SIGNATURE"

You get the normal /usage answer. The same query string works for a job or a batch: put it after https://curlshot.com/api/v1/jobs/YOUR_JOB_ID?.

The file links inside job answers and webhooks need none of this. They carry their own token and work without a key or a signature until they expire.

#Sign a bulk call

A bulk call keeps its options in the body, not in the query. So the signature has to cover the body, or anyone holding the signature could send a different list.

You do that with body_sha256. It is the SHA-256 hash of the body, written as lowercase hex. A hash is a short fingerprint of a text: change one character of the body and the fingerprint changes.

  1. Write the body, and keep the exact text.
  2. Compute the SHA-256 of that text.
  3. Sign access_key and body_sha256, sorted by name as always.
  4. Send the same body text, byte for byte.
Request
ACCESS_KEY="YOUR_ACCESS_KEY"
SECRET_KEY="YOUR_SECRET_KEY"

BODY='{"requests":[{"url":"https://example.com"},{"url":"https://en.wikipedia.org/wiki/Eiffel_Tower","format":"jpeg"}]}'

# The fingerprint of the body. openssl prints a label first, so keep the last word.
BODY_SHA256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')

CANONICAL="access_key=$ACCESS_KEY&body_sha256=$BODY_SHA256"
SIGNATURE=$(printf '%s' "$CANONICAL" | openssl dgst -sha256 -hmac "$SECRET_KEY" -hex | awk '{print $NF}')

curl -X POST "https://curlshot.com/api/v1/bulk?$CANONICAL&signature=$SIGNATURE" \
  -H "Content-Type: application/json" \
  -d "$BODY"

You get the normal 202 answer with a batch_id and one job per request.

If the body that arrives does not match body_sha256, or the signed query holds any other parameter, the call is refused with signature_invalid.

#A POST body cannot change a signed link

Options can also be sent in a POST body. That does not open a way around the signature.

The signature has to cover every option of the request, wherever it is written. If a body adds an option, or gives a signed option another value, the signature no longer matches and the request is refused. A body also cannot remove a signed option by sending it as null.

So a signed link always renders exactly what you signed. The simple way to use signing is the one on this page: put every option in the query, sign it, and send it as a GET.

#Common mistakes

  • The parameters changed after signing. The signature covers every parameter. If you add &format=png to a finished link, it no longer matches. Sign last.
  • You signed one thing and sent another. A common case is signing viewport_width=1280 and sending viewport_width=1280.0, or signing True and sending true. Sign the exact text that goes in the link.
  • A space became +. Many "form" encoders write a space as +. The canonical string needs %20. Use the functions shown above: rawurlencode in PHP, quote(value, safe="") in Python, ERB::Util.url_encode in Ruby.
  • The parameters are not sorted. access_key comes before url. The order in the link you send does not matter, but the order in the string you sign does.
  • signature was part of the signed text. Leave it out of the canonical string. It is added at the end.
  • The wrong secret was used. Each access key has its own secret key. A link with access key A must be signed with the secret of A.
  • The secret key is in the browser. If you sign in front-end JavaScript, your secret is public. Sign on the server.
  • expires is in milliseconds. JavaScript's Date.now() counts milliseconds. Divide by 1000 and round down. A 13-digit value is refused with invalid_options, and a value that is not a whole number of seconds is too.
  • You reused a screenshot signature on another endpoint. /usage, /jobs, /batches, /files and /bulk each need their own short signature. See One signature, one endpoint.
  • The bulk body changed after you hashed it. A JSON library that writes the body again can change spacing or key order. Hash and send the same text.

#Where to go next