Docs · raw markdown
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
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
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
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
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
endwith get "/api/shutterbug/token", to: "shutterbug_tokens#show" in routes.rb.
ASP.NET Core
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. 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.