# Token exchange

Shutterbug accepts reports only from signed-in people, and only the host knows who is signed in. So the host runs one route, behind its own sign-in, that presents the project's secret to Shutterbug and hands the resulting token to the widget. The widget calls that route on the host's own origin, caches the token, and refreshes it thirty seconds before it expires.

## The contract

```http title="POST /api/reporter-tokens"
POST {SHUTTERBUG_URL}/api/reporter-tokens
authorization: Bearer {SHUTTERBUG_PROJECT_SECRET}
content-type: application/json

{
  "projectKey": "{SHUTTERBUG_PROJECT_KEY}",
  "reporter": {
    "id": "<the host's user id>",
    "email": "<email or null>"
  }
}
```

| Status | Body | Meaning |
|---|---|---|
| 200 | `{ "token": "…", "expiresAt": "2026-09-14T12:10:00.000Z" }` | The widget token. Ten minutes. |
| 400 | `{ "error": "invalid_fields", "issues": [...] }` | The body was not the shape above. |
| 401 | `{ "error": "invalid_secret" }` | The secret is wrong, or was rotated more than 24 hours ago. |
| 404 | `{ "error": "unknown_project" }` | No project has that key, or it was deleted. Pass it through: the widget says *This project isn't taking reports.* instead of offering a retry. |
| 410 | `{ "error": "project_archived" }` | The project is archived and takes no reports. The secret is checked first, so this answer means the secret was right. Pass it through: the widget reads a 410 as closed and says *This project is no longer taking reports.* Restoring the project on its Settings opens it again, on the same key and secret. |

The reporter's `id` is stored on every report as the host's own user id; the same id on two projects is two reporters. `email` is optional and may be null.

## The route the host writes

Three rules, whatever the stack: it sits behind the host's sign-in and answers 401 to anyone else; it sends `cache-control: no-store`; it passes Shutterbug's status through.

### Node

```js title="app/api/shutterbug/token/route.js"
export async function GET(request) {
  const user = await currentUserFrom(request);
  if (!user) {
    return Response.json({ error: "sign_in_required" }, { status: 401 });
  }
  const url = process.env.SHUTTERBUG_URL + "/api/reporter-tokens";
  const upstream = await fetch(url, {
    method: "POST",
    headers: {
      authorization: "Bearer " + process.env.SHUTTERBUG_PROJECT_SECRET,
      "content-type": "application/json",
    },
    body: JSON.stringify({
      projectKey: process.env.SHUTTERBUG_PROJECT_KEY,
      reporter: { id: user.id, email: user.email ?? null },
    }),
  });
  return new Response(upstream.body, {
    status: upstream.status,
    headers: {
      "content-type": "application/json",
      "cache-control": "no-store",
    },
  });
}
```

### Laravel

```php title="routes/web.php"
Route::middleware('auth')
    ->get('/api/shutterbug/token', function (Request $request) {
        $user = $request->user();
        $url = config('services.shutterbug.url') . '/api/reporter-tokens';
        $upstream = Http::withToken(config('services.shutterbug.secret'))
            ->post($url, [
                'projectKey' => config('services.shutterbug.key'),
                'reporter' => [
                    'id' => (string) $user->id,
                    'email' => $user->email,
                ],
            ]);
        return response($upstream->body(), $upstream->status())
            ->header('Content-Type', 'application/json')
            ->header('Cache-Control', 'no-store');
    });
```

### Rails

```ruby title="app/controllers/shutterbug_tokens_controller.rb"
class ShutterbugTokensController < ApplicationController
  before_action :authenticate_user!

  def show
    secret = ENV.fetch("SHUTTERBUG_PROJECT_SECRET")
    url = URI("#{ENV.fetch("SHUTTERBUG_URL")}/api/reporter-tokens")
    body = {
      projectKey: ENV.fetch("SHUTTERBUG_PROJECT_KEY"),
      reporter: { id: current_user.id.to_s, email: current_user.email }
    }.to_json
    upstream = Net::HTTP.post(
      url,
      body,
      "Authorization" => "Bearer #{secret}",
      "Content-Type" => "application/json"
    )
    response.headers["Cache-Control"] = "no-store"
    render json: upstream.body, status: upstream.code.to_i
  end
end
```

with `get "/api/shutterbug/token", to: "shutterbug_tokens#show"` in `routes.rb`.

### ASP.NET Core

```csharp title="Program.cs"
app.MapGet("/api/shutterbug/token", async (
    HttpContext http,
    IHttpClientFactory clients,
    IConfiguration config) =>
{
    var user = http.User;
    if (user.Identity?.IsAuthenticated != true)
    {
        var refused = new { error = "sign_in_required" };
        return Results.Json(refused, statusCode: 401);
    }
    var client = clients.CreateClient();
    var secret = config["SHUTTERBUG_PROJECT_SECRET"];
    client.DefaultRequestHeaders.Authorization = new("Bearer", secret);
    var url = config["SHUTTERBUG_URL"] + "/api/reporter-tokens";
    var upstream = await client.PostAsJsonAsync(url, new
    {
        projectKey = config["SHUTTERBUG_PROJECT_KEY"],
        reporter = new
        {
            id = user.FindFirst(ClaimTypes.NameIdentifier)?.Value,
            email = user.FindFirst(ClaimTypes.Email)?.Value,
        },
    });
    http.Response.Headers.CacheControl = "no-store";
    var body = await upstream.Content.ReadAsStringAsync();
    return Results.Content(
        body,
        "application/json",
        statusCode: (int)upstream.StatusCode);
}).RequireAuthorization();
```

## Checking it

Press **Check the route** on the [Install page](/install). Shutterbug sends one GET with no session to `https://<your origin>/api/shutterbug/token` and reads the status: 401 or 403 means the route is there and behind sign-in; 200 means it is not protected; 404 means it is not deployed at that path. Only origins listed for the project are checked, and only public hosts.

## Rotating the secret

Rotate on Settings. The previous secret keeps working for 24 hours, so a deploy with the new one has time to land.
