# Async and webhooks

> Queue renders in the background and receive the result as a signed POST to your webhook.

## Queue a render

With `async=true`, the request returns `202 Accepted` right away and the render runs in the background. When it's done, the result is POSTed to `webhook_url`:

```bash
curl -X POST "https://shotkit.net/api/take" \
  -H "X-Access-Key: YOUR_ACCESS_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com","full_page":true,"async":true,"webhook_url":"https://your.app/hooks/shot","external_identifier":"page-42"}' \
  --fail-with-body -o shot.jpg
```

```json
{ "is_successful": true, "status": "accepted" }
```

Async requests always render; they don't read or write the [cache](https://shotkit.net/docs/caching.md). Use async for slow pages, long [recordings](https://shotkit.net/docs/video.md), and batches where you don't want to hold connections open. `timeout` can go up to 600 seconds with async (90 without).

The monthly quota is checked when you queue the request. Your plan's per-minute rate limit applies to queued requests too.

## The webhook

shotkit POSTs the same response a synchronous request would have returned:

- With the default `response_type`, the body is the file and `Content-Type` its media type.
- With `response_type=json`, the body is the [JSON](https://shotkit.net/docs/responses.md#json) with `screenshot_url` and metadata. This is the easiest to handle, and the only way to get metadata with async.

Headers on the delivery:

| Header | Value |
| --- | --- |
| `Content-Type` | The file's media type, or `application/json`. |
| `X-Signature` | Hex HMAC-SHA256 of the raw body, keyed with your secret key. Omitted with `webhook_sign=false`. |
| `X-External-Identifier` | Your `external_identifier`, if you set one. |

Respond with any `2xx` within 15 seconds. Deliveries aren't retried, so store the body first and process it later.

### Errors

By default, failed renders aren't delivered. With `webhook_errors=true`, the [error JSON](https://shotkit.net/docs/errors.md) is POSTed instead, signed the same way:

```json
{
  "is_successful": false,
  "error_code": "timeout_error",
  "error_message": "Navigation timed out."
}
```

Temporary failures are retried automatically before an error is sent: when all browsers are busy, every 15 seconds for up to an hour; after an infrastructure error, up to three times. Errors caused by the request or the page, such as an invalid selector or a `404`, fail right away.

## Verify the signature

Compute the HMAC-SHA256 of the **raw** request body (before any JSON parsing) with your secret key and compare it with `X-Signature` in constant time:

```js tab="Node.js"
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const app = express();

app.post("/hooks/shot", express.raw({ type: "*/*", limit: "50mb" }), (req, res) => {
  const expected = createHmac("sha256", process.env.SHOTKIT_SECRET_KEY).update(req.body).digest("hex");
  const given = req.get("x-signature") ?? "";
  if (given.length !== expected.length || !timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
    return res.sendStatus(401);
  }
  // req.body is the file (or JSON), req.get("x-external-identifier") is your id.
  res.sendStatus(204);
});
```

```ts tab="Next.js"
import { createHmac, timingSafeEqual } from "node:crypto";

export async function POST(req: Request) {
  const body = Buffer.from(await req.arrayBuffer());
  const expected = createHmac("sha256", process.env.SHOTKIT_SECRET_KEY!).update(body).digest("hex");
  const given = req.headers.get("x-signature") ?? "";
  if (given.length !== expected.length || !timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
    return new Response(null, { status: 401 });
  }
  const id = req.headers.get("x-external-identifier");
  // Store `body` for `id`…
  return new Response(null, { status: 204 });
}
```

```python tab="Python"
import hashlib, hmac, os
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/hooks/shot")
def shot():
    body = request.get_data()
    expected = hmac.new(os.environ["SHOTKIT_SECRET_KEY"].encode(), body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-Signature", "")):
        abort(401)
    external_id = request.headers.get("X-External-Identifier")
    # Store `body` for `external_id`…
    return "", 204
```

```php tab="PHP"
<?php
$body = file_get_contents("php://input");
$expected = hash_hmac("sha256", $body, getenv("SHOTKIT_SECRET_KEY"));
if (!hash_equals($expected, $_SERVER["HTTP_X_SIGNATURE"] ?? "")) {
    http_response_code(401);
    exit;
}
$externalId = $_SERVER["HTTP_X_EXTERNAL_IDENTIFIER"] ?? null;
// Store $body for $externalId…
http_response_code(204);
```

```ruby tab="Ruby"
require "openssl"
require "sinatra"

post "/hooks/shot" do
  body = request.body.read
  expected = OpenSSL::HMAC.hexdigest("SHA256", ENV["SHOTKIT_SECRET_KEY"], body)
  halt 401 unless Rack::Utils.secure_compare(expected, request.env["HTTP_X_SIGNATURE"].to_s)
  external_id = request.env["HTTP_X_EXTERNAL_IDENTIFIER"]
  # Store body for external_id…
  status 204
end
```

```go tab="Go"
func shot(w http.ResponseWriter, r *http.Request) {
	body, _ := io.ReadAll(r.Body)
	mac := hmac.New(sha256.New, []byte(os.Getenv("SHOTKIT_SECRET_KEY")))
	mac.Write(body)
	expected := hex.EncodeToString(mac.Sum(nil))
	if !hmac.Equal([]byte(expected), []byte(r.Header.Get("X-Signature"))) {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}
	externalID := r.Header.Get("X-External-Identifier")
	// Store body for externalID…
	_ = externalID
	w.WriteHeader(http.StatusNoContent)
}
```
