One hour to your first submission.
Three packages. The core, @innspel/core, has no dependencies, no browser APIs, and is under 10 kB gzipped — a CI test fails if it ever grows past that. @innspel/react-native is the widget for iOS and Android. @innspel/web is the same components for web pages, and also powers the portal on your own subdomain.
Quickstart
No analytics SDK, no crash reporting, no advertising ID. The client library has no stable identifier and stores only what it needs to show "My submissions".
These are complete files, not excerpts, and they are run in the test suite of the SDK itself. In React Native:
client/react-native.tsx// Innspel — the client in React Native (@innspel/react-native).
//
// Two peer dependencies you install yourself: react-native-safe-area-context and
// react-native-svg (see the guide). Storage is injected — the core knows nothing about
// AsyncStorage — and `identityProvider` calls YOUR token endpoint (guide, section 1).
//
// Complete: Innspel's CI type-checks this file against the published packages.
import AsyncStorage from '@react-native-async-storage/async-storage';
import { init } from '@innspel/core';
import { createStorage, InnspelPrivate, InnspelProvider, useInnspel } from '@innspel/react-native';
import { Pressable, Text, View } from 'react-native';
/** Calls YOUR token endpoint with your own session. Return null when nobody is signed in. */
async function getFeedbackToken(): Promise<string | null> {
const res = await fetch('https://your-app.example/api/innspel-token', { headers: { Authorization: `Bearer ${yourSession()}` } });
if (!res.ok) return null;
return ((await res.json()) as { token: string }).token;
}
const innspel = init({
baseUrl: 'https://api.innspel.no',
tenantKey: 'innspel_pk_REPLACE_ME', // the SDK key — Settings → "SDK-nøkler" in the panel. Public, write-only
identityProvider: getFeedbackToken, // or null = anonymous mode
storage: createStorage(AsyncStorage), // the offline queue lives here
context: { platform: 'ios', appVersion: '1.4.2' },
});
export default function App() {
return (
<InnspelProvider client={innspel}>
<Home />
</InnspelProvider>
);
}
function Home() {
const { open } = useInnspel();
return (
<View>
{/* Never in a screenshot: masked before the submitter sees the preview, cannot be unmasked. */}
<InnspelPrivate>
<Text>Account 1234 56 78903</Text>
</InnspelPrivate>
<Pressable accessibilityRole="button" onPress={() => void open({ screen: 'Home' })}>
<Text>Send feedback</Text>
</Pressable>
</View>
);
}
function yourSession(): string {
return 'your-own-session-token';
}
On the web, with your own button:
client/web.ts// Innspel — the client on the web (@innspel/web, no framework).
//
// Your button, your element, one call. `mount` renders the dialog into `el` when the
// button is pressed; `bindTrigger` gives the button `aria-haspopup="dialog"` and returns
// focus to it when the dialog closes. Innspel adds no floating button of its own.
//
// Complete and runnable: Innspel's own test suite calls `startInnspel` in a browser DOM,
// presses the button and expects the dialog. The third argument exists only for that —
// a real page passes two.
import { mount, type MountOptions, type Mounted } from '@innspel/web';
/** Calls YOUR token endpoint (see the guide, section 1) with your own session. */
export async function getFeedbackToken(): Promise<string | null> {
const res = await fetch('/api/innspel-token', { credentials: 'include' });
if (!res.ok) return null; // not signed in: this submission goes out anonymously
return ((await res.json()) as { token: string }).token;
}
export function startInnspel(el: HTMLElement, button: HTMLElement, overrides: Partial<MountOptions> = {}): Mounted {
const innspel = mount(el, {
baseUrl: 'https://api.innspel.no',
tenantKey: 'innspel_pk_REPLACE_ME', // the SDK key — Settings → "SDK-nøkler" in the panel. Public, write-only
identityProvider: getFeedbackToken,
appVersion: '2.4.1',
...overrides,
});
innspel.bindTrigger(button);
return innspel;
}
// In your page:
// <button id="send-feedback">Send feedback</button>
// <div id="innspel"></div>
// startInnspel(document.getElementById('innspel')!, document.getElementById('send-feedback')!);
Anonymous mode is the same call with identityProvider: null — a choice, never a fallback:
client/anonymous.ts// Innspel — anonymous mode (@innspel/web; the same option exists in @innspel/core).
//
// `identityProvider: null` is a choice, not a fallback: no token is ever asked for, there is
// no pseudonym, no "My submissions", and the submission never counts in any statistic of how
// many people are affected. The platform cannot reach the submitter, and neither can you.
// A provider that RETURNS null means "not signed in right now" — that is a different thing.
import { mount, type MountOptions, type Mounted } from '@innspel/web';
export function startInnspelAnonymously(el: HTMLElement, button: HTMLElement, overrides: Partial<MountOptions> = {}): Mounted {
const innspel = mount(el, {
baseUrl: 'https://api.innspel.no',
tenantKey: 'innspel_pk_REPLACE_ME',
identityProvider: null, // anonymous mode
appVersion: '2.4.1',
...overrides,
});
innspel.bindTrigger(button);
return innspel;
}
What you build
- A token endpoint. The only mandatory part. Your server issues an ES256-signed token with
aud=innspel, a pseudonym assub, and a lifetime of at most 15 minutes. You register the public key in the panel. - A webhook receiver, if you want to know when something happens. HMAC-signed, five-minute timestamp window, idempotent on event ID.
- Origins and bundle IDs registered on the product, if you use the web.
Step 1 plus the quickstart is the hour. The rest can wait until you have seen your first case in the inbox.
Integration guide
Rendered at build time from the same file the repository uses — one source, two outputs. What you need from the panel, the token endpoint, webhooks, the portal login route, origins, the server-to-server API, rate limits, errors, the privacy statement, screenshots, and "the order that works".
This guide describes what you, the tenant, have to provide — and only that. How to use
the SDK is in the package READMEs: @innspel/core,
@innspel/react-native and
@innspel/web. Complete, runnable examples are in
examples/; every one of them is verified in Innspel's own test suite
against the API's token and signature verification.
The goal is that one developer goes from npm install to a submission in the inbox in
under an hour. If it takes you longer, that is a bug in this guide — tell us.
Three things to put in place:
- A token endpoint that issues the feedback token. The only mandatory part.
- A webhook receiver, if you want to know when something happens to a submission.
- Origins registered on the product, if you use the web widget or the portal.
0. What you need from the panel
| What | Where in the panel | Used as |
|---|---|---|
| API address | https://api.innspel.no — also shown under Organisation | baseUrl in the SDK; the base of every URL below |
| Tenant ID | Organisation → "Leietaker-ID og API-adresse" (copy button) | iss in the feedback token |
| Product ID | Settings → "Produkter og SDK-nøkler", under each product (copy button) | prd in the feedback token |
SDK key (innspel_pk_…) | Settings → "SDK-nøkler" | tenantKey in the SDK. Public, write-only (section 3) |
| Signing key | Organisation → "Signeringsnøkler": a JWKS URL. Uploading a PEM/JWK instead is done over the admin API (PATCH /v1/admin/settings, publicKeys) | How Innspel verifies your tokens (section 1) |
API key (innspel_sk_…) | Organisation → "Tilgang" → "API-nøkler", with scopes | X-Innspel-Api-Key on the server-to-server API (section 4) |
| Webhook | Organisation → "Utgående" → "Mottakere" | Section 2 |
| Segment keys | Organisation → "Segmentnøkler" | Reserved for the seg claim (section 1) |
Tenant ID and product ID are not secrets — they are in every token you issue. The SDK key is public by design. The API key, the webhook secret and your private signing key are secrets, and each is shown exactly once.
React Native has two peer dependencies you install yourself. The widget keeps them outside the package because they contain native code: two copies in one app is a build error.
npx expo install react-native-safe-area-context react-native-svg # Expo
npm i react-native-safe-area-context react-native-svg # bare React Native
cd ios && pod install # bare React Native
react-native-svg (15.2 or newer) draws the icons — Ionicons, inlined as SVG in the package,
never an icon font and never loaded over the network. react-native-safe-area-context (4.0 or
newer) gives the sheet the height of the system bars on Android. If either is missing, the build
step fails with its name; the widget never ships with invisible icons.
1. The token endpoint
Innspel never owns your end users. The platform does not know who sends a submission — only that someone you have confirmed is signed in sent it. The bridge between the two is a short-lived token you sign yourself.
Why not just send your own JWT?
Because it carries personal data and is a bearer token against your own API. Sending it to
Innspel would let the platform learn who the user is and act as them against you. That is
rejected in tech-decisions.md ("Identitet"), and it is why you issue a separate, narrower token.
What the token must contain
| Field | Value | Why |
|---|---|---|
alg (header) | ES256 | The only accepted algorithm. RS256 and HS256 are rejected |
kid (header) | The ID of the key you signed with | Required. Only the key with that kid is tried, so a rotated-out key can be revoked |
iss | Your tenant ID (Organisation in the panel) | Must match the tenant the SDK key belongs to |
aud | innspel | Makes the token useless against your own services |
prd | The product ID (the product the SDK key belongs to) | Binds the token to one app |
sub | The pseudonym. An HMAC of your user ID | See below. Never the user ID itself |
iat | Now | Lifetime is measured as exp − iat; without iat, from the time of the call |
exp | At most 15 minutes ahead | A token with a longer lifetime is rejected even before it expires |
nbf | Optional | If present, must not be in the future |
seg | Optional — reserved | The claim is accepted but not read yet. The allow-list under "Segmentnøkler" is stored and not applied. Do not rely on it until this line changes |
Clock skew of 60 seconds is tolerated on exp, nbf and the lifetime.
The pseudonym
sub is the only subject the platform ever sees. It must be stable for the same user —
otherwise they cannot see their own submissions — and impossible to compute back to your
user ID. An HMAC-SHA256 of your internal user ID with a secret that never leaves your
server does both:
import { createHmac } from 'node:crypto';
// Rotating this secret severs every user from their past submissions — treat it as permanent.
const pseudonym = (userId: string) =>
createHmac('sha256', process.env.INNSPEL_PSEUDONYM_SECRET!).update(userId).digest('base64url');
Use your internal user ID, not e-mail or phone number: when the user changes their e-mail, their submissions should follow.
The endpoint
A route your app calls with its own session, which returns { "token": "<jwt>" } with
Cache-Control: no-store. The complete, runnable versions are
examples/node/token-endpoint.mjs (Node 24, no
dependencies) and
examples/dotnet/Innspel.Examples/FeedbackTokens.cs
with Program.cs (.NET 10). The core of the
Node one:
const payload = {
iss: TENANT_ID,
aud: 'innspel',
sub: pseudonym(user.id),
prd: PRODUCT_ID,
iat,
exp: iat + 15 * 60,
};
// header { alg: 'ES256', typ: 'JWT', kid: KEY_ID }, signed with your EC P-256 private key.
Any JWT library does the same (jose, System.IdentityModel, …); the rules in the table are
what matters.
The key pair
Create it yourself and register the public part:
openssl ecparam -genkey -name prime256v1 -noout -out innspel-private.pem
openssl pkcs8 -topk8 -nocrypt -in innspel-private.pem -out innspel-private-pkcs8.pem
openssl ec -in innspel-private.pem -pubout -out innspel-public.pem
Two ways to register it:
- A JWKS URL under Organisation → "Signeringsnøkler". Innspel fetches it, keeps the keys
for 10 minutes, and fetches again when a token arrives with an unknown
kid(at most once every 30 seconds). Only EC P-256 keys are read. If the URL is down, no key is known and every token is rejected withfeedback_token_invaliduntil it is back. - Uploaded keys (PEM or JWK, with the
kid) over the admin API:PATCH /v1/admin/settingswithpublicKeys. Both sources are used together.
The private key never leaves your server. Innspel has only the public one and can verify signatures — never create them.
Connecting it to the SDK
const getFeedbackToken = async () => {
const res = await fetch('https://your-app.example/api/innspel-token', {
headers: { Authorization: `Bearer ${yourOwnSession}` },
});
if (!res.ok) return null; // null = anonymous for this call
return (await res.json()).token;
};
const innspel = init({ baseUrl: 'https://api.innspel.no', tenantKey: 'innspel_pk_…', identityProvider: getFeedbackToken, /* … */ });
The SDK calls this every time it needs a token and never caches it on disk. If the user is not
signed in, return null — the submission goes out anonymously instead of blocking.
identityProvider: null (the option itself) is anonymous mode: no token is ever asked for.
The complete client files: React Native,
web with your own button and
anonymous mode.
Troubleshooting
| Symptom | Likely cause |
|---|---|
feedback_token_invalid | Wrong aud or iss, missing or unknown kid, not ES256, prd is not the product the SDK key belongs to, missing sub, lifetime over 15 minutes, or the JWKS URL could not be fetched |
feedback_token_expired | The token has expired. The SDK fetches a new one and retries — seeing this in your own log is expected |
feedback_token_required | The call came without a token on an operation that needs an identity ("my submissions", own status, beta programmes) |
| The submission lands as anonymous | identityProvider returned null or threw. Check the console for [innspel] |
Authorization with another scheme | Anything but Bearer <jwt> is an invalid token, not anonymous mode |
All of these are errors in this integration, not in your app at large.
2. Webhooks
Innspel never sends push and never holds push tokens. If you want to tell the user that their
bug is fixed, the webhook is the way: the platform tells you that something happened, and
you notify the user (tech-decisions.md, "Sløyfen ut").
Register an https URL under Organisation → "Utgående" → "Mottakere". It is created inactive
and unverified; nothing is delivered until verification has passed.
The payload never carries the submission text, internal notes or screenshots — only IDs, the pseudonym and the status. If you want to show the user the text, they already have it under "My submissions".
The events
submission.created submission.status_changed
submission.replied submission.deadline_warning
known_issue.fixed subject.deleted
program.invitation_created program.participant_joined
program.participant_withdrawn program.segment_changed
program.follow_up_due program.closed
The body
POST with Content-Type: application/json; charset=utf-8. The envelope is the same for every
event; data is the only thing that varies, and the body is byte-identical on every attempt:
{
"id": "01a07636-a18f-7677-bda9-448447261685",
"type": "submission.status_changed",
"createdAt": "2026-09-06T10:14:46.9282230Z",
"data": {
"id": "01a07630-1111-7000-8000-000000000001",
"number": 42,
"productId": "01a07630-2222-7000-8000-000000000002",
"subject": "the-pseudonym-your-token-endpoint-set",
"type": "feil",
"status": "levert",
"statusType": "complete",
"fixedIn": "2.4.0",
"knownIssueId": null,
"createdAt": "2026-09-05T08:00:00.0000000Z",
"statusChangedAt": "2026-09-06T10:14:46.9000000Z"
}
}
subject is the pseudonym your own token endpoint put in sub. For an anonymous submission
it is null — the platform cannot resolve pseudonyms; only you can.
What data carries, per event:
| Event | data |
|---|---|
submission.created, submission.status_changed | id, number, productId, subject (null = anonymous), type (feil | onske), status, statusType (reviewing | active | complete | closed), fixedIn?, knownIssueId?, createdAt, statusChangedAt |
submission.replied | the same, plus repliedAt |
submission.deadline_warning | the same, plus deadlineAt |
known_issue.fixed | id, productId, programId?, status, fixedIn, fixedAt, confirmedSubmissionIds[], confirmedSubjects[], relatedSubmissionIds[], relatedSubjects[], reach, anonymousCount — every subject who pressed "me too" or whose submission was linked, so you can notify them |
program.invitation_created | id, programId, subject, token (the innspel_inv_… invitation token you hand to the user), expiresAt, createdAt |
program.participant_joined | id, programId, subject, joinedAt |
program.participant_withdrawn | id, programId, subject, withdrawnAt |
program.segment_changed | programId, subjects[], updatedAt — emitted when a participant leaves; export the segment (section 4) and confirm your flag |
program.follow_up_due | id, programId, participantId, subject, kind, question, deadlineAt, dueAt, emittedAt, contactNumber |
program.closed | programId, status, reason?, closedAt; when closed with a report also frozenAt, conclusion, nextSteps, openBlocking, dataLimitations, payload |
subject.deleted | id (the receipt), subject, status, acceptedAt, completedAt, submissions, attachments, programRecords — the counts of what was deleted |
Headers
| Header | Content |
|---|---|
X-Innspel-Signature | t=<unix seconds>,v1=<HMAC-SHA256, hex, lower case> |
X-Innspel-Event-Id | The idempotency key — the same as id in the body, on every attempt |
X-Innspel-Event-Type | The same as type in the body — lets you route before parsing |
X-Innspel-Attempt | 1 on the first attempt, 2 on the next, and so on |
Verifying the signature
What is signed is the string "{t}.{body}" — the timestamp from the header, a dot, and the
body exactly as it arrived. The key is the characters of your secret, the innspel_whsec_
prefix included. No decoding, no normalisation. Reject anything whose t is more than
5 minutes from your clock, even with a valid signature: without the window, an old, genuine
delivery could be replayed at any time. Compare in constant time — === on hex strings leaks
how many characters matched.
The complete receivers are examples/node/webhook-receiver.mjs
and examples/dotnet/Innspel.Examples/WebhookSignatures.cs.
The core of the Node one:
const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
const actual = Buffer.from(v1, 'hex');
const valid = Math.abs(Date.now() / 1000 - t) <= 300
&& expected.length === actual.length && timingSafeEqual(expected, actual);
Be idempotent on id
The same id can arrive several times and must be handled once.
Mark it as handled only when handling actually succeeded. If you mark it on receipt, attempt 2 after a failed handling is rejected as a duplicate, and the event is lost for good. A genuine duplicate — something you already handled — gets 2xx; that is exactly what makes the platform stop retrying.
Answer fast with 2xx
The timeout is 10 seconds. Do the work in the background and acknowledge at once.
Anything but 2xx is a failed attempt, 3xx included: the platform never follows a redirect.
On failure it tries five times in all — the first attempt immediately, then after 1 min, 5 min,
30 min and 2 hours — and then puts the delivery in the dead-letter list, visible in the panel,
where it can be re-sent manually with the same id.
Your address is resolved anew on every attempt and must be https to a public address each
time. A URL that resolves to a private, loopback, link-local or multicast address is not
delivered to.
Verification and the test button
Two requests have a type outside the list above, never come again, and are not events:
type | When | What you must do |
|---|---|---|
webhook.verify | You press "Verifiser" in the panel | Answer 2xx with the value of data.challenge somewhere in the response body — {"challenge":"…"} or plain text both work. Only then is the webhook marked verified, and only then is anything delivered |
webhook.test | You press "Send test" in the panel | Answer 2xx. One attempt, no retry. data is {} |
Both are signed exactly like a real delivery, so run them through the same verification.
The secret
Shown once only — in the response when the webhook is created, and in the response when you rotate. The platform does not store it and cannot show it again.
If you rotate, the old secret is invalid immediately; there is no overlap window. Change it in your receiver first, then rotate — the other way round, deliveries fail in between, and the retry curve covers the gap.
2b. The login route for the portal (portal only)
The portal on your subdomain never signs the user in itself — it sends them to your login page, which issues the same feedback token as above and sends them back. One more route, next to the token endpoint:
GET /innspel/login?return=<portal URL>
- Authenticate the user as you otherwise do (they may already be signed in).
- Create the feedback token exactly as in section 1.
- Send them back to
returnwith the token in the URL fragment:302 Location: <return>#token=<jwt>.
The fragment is never sent to any server and does not end up in logs or Referer; the portal
takes it out, keeps it in sessionStorage and removes it from the address bar at once. Accept
only return values on your own portal domain — otherwise the route is an open redirect with
a token in tow.
The portal gets its configuration at build time (portal/.env.example): your login URL,
your privacy statement, name, limits and palette. Origins for the portal's domain are
registered on the product like any other web origin (section 3).
3. The SDK key, origins and bundle IDs
The SDK key (innspel_pk_…) is public and write-only. It sits in your app bundle and can
be read by anyone — which is why it can never read or list anything, only submit (K3). It is
sent as X-Innspel-Tenant-Key and identifies both the tenant and the product.
Register on the product in the panel (Settings → "Produkter og SDK-nøkler"):
origins— web origins, e.g.https://app.example.com. When a request carries anOriginheader, it must be one of these (compared case-insensitively, without a trailing slash), or the request is rejected with 401. A request without anOriginheader is not checked — native apps do not send one.bundleIds— iOS bundle IDs and Android application IDs. These are documentation, not enforcement: a bundle ID cannot be verified on the server. What limits the damage a leaked key can do is that it can only write, that it is rate limited (section 5), and that you can rotate it.
The key can be rotated with two valid at the same time, so you can roll out a new app version before you revoke the old key.
4. The server-to-server API
For things your backend does on behalf of the tenant — not the app on behalf of a user. The
key is an API key (innspel_sk_…), created under Organisation → "Tilgang" → "API-nøkler"
with the scopes it needs, shown once. Send it as X-Innspel-Api-Key. A key without the
required scope gets 403 insufficient_scope.
| Call | Scope | What it does |
|---|---|---|
DELETE /v1/tenant/subjects/{sub} | subjects:delete | Delete everything about a pseudonym: submissions, attachments and programme records. Answers 202 with a DeletionReceipt — also for an unknown pseudonym — and completes within 24 hours. This is deletion, not anonymisation |
GET /v1/tenant/deletions/{id} | subjects:delete | The receipt: status, when it was accepted and completed, and the counts |
GET /v1/tenant/programs/{id}/segment | segment:read | The pseudonyms in a beta programme, for your feature flag. Carries an ETag; If-None-Match gives 304 |
POST /v1/tenant/programs/{id}/participants/{sub}/flag-confirmed | programs:confirm | Confirm that your flag is on for a participant — the platform never flips flags itself |
GET /v1/tenant/programs/{id}/report | admin | The frozen report of a closed programme |
PATCH /v1/tenant/submissions/{id}/status | admin | Set a submission's status from your own tooling ({status, fixedIn?}), with the same rules as the panel |
The scope webhooks exists in the contract but no call requires it today; webhooks are
managed in the panel.
Every call is scoped to the tenant the key belongs to. An ID that belongs to another tenant is 404, never 403 — the API does not reveal that it exists.
5. Rate limits
On the ingest surface (what the SDK calls), measured over a sliding one-minute window:
| Per | Limit |
|---|---|
| SDK key | 300 requests / minute |
Pseudonym (sub) | 60 requests / minute — anonymous calls have no per-subject limit |
| IP address | 60 requests / minute |
Over the limit the answer is 429 rate_limited with Retry-After in seconds (at least 1).
The SDK surfaces it to the app as an InnspelApiError with retryAfter (seconds) and does
not queue the submission — only network failures are queued. In practice one user never
reaches 60 requests a minute; the per-key limit is the one a leaked key meets (section 3).
6. Errors
Every error is { "code": "<snake_case>", "message": "<for the developer>" }. Route on
code; message is for your log and may change. The codes you can meet from the SDK, the
portal login and the server-to-server API:
| Status | Codes |
|---|---|
| 400 | validation_failed, unknown_field (a context key outside the allow-list), text_too_short, text_too_long, correlation_invalid |
| 401 | unauthenticated (missing or invalid key — deliberately one code), feedback_token_required, feedback_token_invalid, feedback_token_expired |
| 403 | insufficient_scope, screenshots_disabled, not_submitter |
| 404 | submission_not_found, known_issue_not_found, program_not_found, invitation_invalid, task_not_found, follow_up_not_found, participant_not_found, subject_not_found, attachment_not_found, receipt_not_found, report_not_found, handoff_invalid |
| 409 | invalid_status_transition, submission_closed, addendum_limit_reached, invitation_already_answered, program_full, task_already_completed, follow_up_already_answered, follow_up_not_answerable, attachment_already_uploaded |
| 413 | attachment_too_large |
| 415 | attachment_type_not_allowed |
| 422 | fixed_in_required, minors_present_conflict |
| 429 | rate_limited |
| 501 | not_implemented (/v1/ingest/handoffs, reserved) |
The admin API (the panel's own) has more codes — roles, replies, hypotheses, programme
administration — all in the same format; the full list is ErrorCode in the OpenAPI
contract.
The calls the SDK makes, for reference (/v1/ingest/…, all with X-Innspel-Tenant-Key):
| Token | Calls |
|---|---|
| Optional (anonymous allowed) | GET /config · POST /events (telemetry; a token is ignored) · GET /known-issues · POST /known-issues/{id}/confirm · POST /submissions · POST /attachments/presign · POST /attachments/{id}/complete |
| Required | GET /submissions · GET /submissions/{id}/status · POST /submissions/{id}/addenda · GET /programs · POST /invitations/{token}/accept and /decline · POST /tasks/{id}/complete · POST /follow-ups/{id}/respond · POST /participations/{id}/withdraw |
7. The privacy statement
Innspel stores the submission text, and free text is personal data however careful you are. Before you take real submissions you need
- a privacy statement that names Innspel as a data processor, linked from the widget
(
privacyUrlin the settings), - a data-processing agreement between you and Innspel — Innspel provides the agreement with its annexes,
- a decision on
minorsPresent— if the service has minors among its users, everything tightens automatically: 140 characters, screenshots off, community off, beta programmes without obligation.
Deletion is per pseudonym within 24 hours, with a receipt (section 4), and is actual deletion — not anonymisation.
8. Screenshots and private views
Screenshots are off until you turn them on in the settings (screenshotsEnabled), and can
never be turned on with minors among the users. If you turn them on, there are three things
to do in the app:
- Give the widget capture. The package has no dependency of its own; pass in
captureReffromreact-native-view-shot:<InnspelProvider screenshot={createScreenshotAdapter(captureRef)}>. Whether the step exists is governed by the setting in the panel: the widget fetches your configuration (loadSettings()) when it opens.screenshotsEnabledininit()is only the initial value until the answer arrives — the SDK starts as strict as possible. - Mark what is private. Everything that must never be in a picture — names, account
numbers, conversations — is wrapped in
<InnspelPrivate>. It is masked before the submitter sees the preview, and cannot be unmasked. This is YOUR responsibility: the widget cannot know what is personal data in your screens. - Know what happens to the picture. It is taken only when the submitter taps, never automatically. It is masked on the device and uploaded straight to storage with a signed URL; the platform re-encodes it in an isolated process and strips EXIF. It is deleted with the submission, and in any case after 90 days.
8b. Connectivity (optional, React Native)
The queue works without you doing anything: if a call fails, the text is stored and sent the
next time the widget opens. But the widget can only say "No connection" before the
submitter taps if the app tells it so. On the web the widget reads navigator.onLine itself;
in React Native that does not exist, and the package does not pull in a dependency to read
the network (K19). So pass in your own source, as you already do for storage and the token:
import NetInfo from '@react-native-community/netinfo';
const innspel = init({
// …
connectivity: {
isOnline: () => online,
subscribe: (listener) => NetInfo.addEventListener((s) => listener(Boolean(s.isConnected))),
},
});
isOnline() is read when the widget opens; subscribe() is optional and returns an
unsubscribe. With it, the "No connection" banner shows before the tap, Send reads "Save — sent
when you are online", and the queue is flushed the moment the network is back — never at
start-up (DD-41). Without the option, everything is as before.
8c. "My submissions" as its own entry point
From
@innspel/core0.10 /@innspel/react-native0.10 the submitter can add more to an open case from the list ("Add more", DD-69):innspel.subjects.addAddendum(id, text), at most three addenda, and the server decides when the button exists (canAddAddendum). It is not a conversation — the case keeps one reply from you, and the addenda are shown on the case in the panel.
The widget already has the overview: status, the promise, and your reply under the case. It sits as a button on the first step, with a count when the submitter has earlier submissions. Also give it its own item in your profile screen, and use it as the landing place when you notify about a status change:
// A "My submissions" item in the profile, or onPress on a status-change notification
await innspel.openFeedback({ view: 'mine' });
// in React: const { open } = useInnspel(); open({ view: 'mine' });
In anonymous mode there is no list, and the option is ignored — the widget opens as usual. Offer the item only when the app has a signed-in user.
9. Customer lookup from the panel (optional)
The panel shows the pseudonym on a case, never your customer number. If your support team
needs to get from a case to your own customer system, set customerLookupUrlTemplate in the
settings (PATCH /v1/admin/settings) to a URL with exactly one {sub} — the platform fills
in the pseudonym, URL-encoded, and your system resolves it, since only you hold the mapping.
Correlation kinds (correlationKinds, e.g. a crash ID) work the same way with a
linkTemplate and {value}. The panel shows the pseudonym on the case with a copy button,
"Åpne kunden" (open the customer) when the template is set, and "Samme innsender" (same
submitter) with the submitter's other cases. The template itself is set under Settings →
"Kundeoppslag".
The order that works
- Create the key pair, register the public key (JWKS URL) in the panel
- Build the token endpoint, verify that it issues an ES256 token with the right
aud,issandprd— the IDs are under Organisation and the product npm install @innspel/core, copy the quickstart, send one submission- See it in the inbox
- Then: the webhook receiver, origins, the privacy statement
Steps 1–4 are the hour. The rest can wait until you know it works.
Examples
Everything below is a complete file, not an excerpt. The files live in the repository and are run in its test suite against the same code the API uses to accept a token and a signature: a token from each example must be accepted, a signature must verify, and a tampered body or a stale timestamp must be rejected. An example that stops working stops the build.
The pseudonym. A keyed hash of your own user ID. The secret never leaves your server, and if you rotate it, existing submissions lose their link to their senders — that is a one-time decision. Both token examples contain it.
The token endpoint. One endpoint behind your own login. It returns an ES256 token addressed to Innspel, valid for at most 15 minutes, with the pseudonym as subject. The Node version uses only node:crypto; the .NET version uses Microsoft.IdentityModel.JsonWebTokens with an ECDsa key and the same claims.
The webhook receiver. Read the body raw, check the timestamp against a five-minute window, compute the HMAC over t.body, and compare in constant time. Answer the verification challenge, and handle each event ID once.
The client in React Native. init with tenantKey, identityProvider and storage from @innspel/react-native, InnspelProvider around your app, and a button that calls openFeedback. That is all of it.
The client on the web. mount on an element, bindTrigger on your button. The button is yours; Innspel adds none of its own to your page.
Anonymous mode. The same code with identityProvider: null. No token, no pseudonym, no "My submissions" — and the submission does not count in any statistic of how many people are affected. It is a choice you make, not something that happens because you forgot an option.
Examples: Node
Node 24, no dependencies. Each file is a server when run without arguments; the flags at the top are what the test suite uses.
node/token-endpoint.mjs// Innspel — feedback-token endpoint in Node (no dependencies, Node 24).
//
// Your app calls this endpoint with the user's own session, and hands the
// token to the SDK. Innspel verifies it against the PUBLIC key you registered
// in the panel (Organisation → Signing keys) and learns nothing about who the
// user is: `sub` is a pseudonym, never your user ID.
//
// This file is complete and runnable:
// node token-endpoint.mjs → HTTP server on $PORT (default 3000)
// node token-endpoint.mjs --issue 42 → prints a token for user "42" (used by Innspel's CI)
//
// Configuration (environment):
// INNSPEL_TENANT_ID tenant ID — Organisation in the panel (`iss`)
// INNSPEL_PRODUCT_ID product ID — Products and SDK keys in the panel (`prd`)
// INNSPEL_KEY_ID `kid` of the key you registered
// INNSPEL_PRIVATE_KEY the PRIVATE key, EC P-256, PKCS#8 PEM — never leaves your server
// INNSPEL_PSEUDONYM_SECRET the HMAC secret behind the pseudonym — rotating it severs every
// user from their past submissions, so treat it as permanent
//
// Any JWT library (e.g. `jose`) does the same thing; the rules are what matters:
// ES256 only, `kid` in the header, `aud` = "innspel", `iss` = tenant ID, `prd` = product ID,
// `sub` = pseudonym, `exp` at most 15 minutes after `iat`.
import { createHmac, createPrivateKey, sign } from 'node:crypto';
import { createServer } from 'node:http';
export const AUDIENCE = 'innspel';
export const LIFETIME_SECONDS = 15 * 60; // the maximum Innspel accepts
/** Stable, irreversible pseudonym for a user. Use your internal user ID, never e-mail or phone. */
export function pseudonym(secret, userId) {
return createHmac('sha256', secret).update(userId).digest('base64url');
}
/**
* Issue a feedback token for `userId`.
* `segment` is optional and may only carry attributes you allow-listed in the panel
* (Organisation → Segment keys); anything else is discarded by Innspel.
*/
export function issueFeedbackToken(config, userId, segment = undefined, now = Date.now()) {
const iat = Math.floor(now / 1000);
const header = { alg: 'ES256', typ: 'JWT', kid: config.keyId };
const payload = {
iss: config.tenantId,
aud: AUDIENCE,
sub: pseudonym(config.pseudonymSecret, userId),
prd: config.productId,
iat,
exp: iat + LIFETIME_SECONDS,
...(segment ? { seg: segment } : {}),
};
const signingInput = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(payload))}`;
// ES256 is ECDSA P-256 over SHA-256 with the raw r||s signature (IEEE P1363), not DER.
const signature = sign('sha256', Buffer.from(signingInput), {
key: createPrivateKey(config.privateKeyPem),
dsaEncoding: 'ieee-p1363',
});
return `${signingInput}.${signature.toString('base64url')}`;
}
const base64url = (text) => Buffer.from(text, 'utf8').toString('base64url');
export function configFromEnv(env = process.env) {
const read = (name) => {
const value = env[name];
if (!value) throw new Error(`${name} is not set`);
return value;
};
return {
tenantId: read('INNSPEL_TENANT_ID'),
productId: read('INNSPEL_PRODUCT_ID'),
keyId: read('INNSPEL_KEY_ID'),
privateKeyPem: read('INNSPEL_PRIVATE_KEY'),
pseudonymSecret: read('INNSPEL_PSEUDONYM_SECRET'),
};
}
/**
* Replace with your own session check. Return the signed-in user, or null — the SDK then
* submits anonymously instead of blocking.
*/
function authenticate(req) {
const session = req.headers.authorization?.replace(/^Bearer /, '');
if (!session) return null;
return { id: session, plan: 'standard' }; // look the session up in your own store
}
function serve(config) {
const server = createServer((req, res) => {
if (req.method !== 'GET' || req.url !== '/api/innspel-token') {
res.writeHead(404).end();
return;
}
const user = authenticate(req);
if (!user) {
res.writeHead(401).end();
return;
}
const token = issueFeedbackToken(config, user.id, { plan: user.plan });
// Never cache: the token is short-lived on purpose.
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(JSON.stringify({ token }));
});
server.listen(Number(process.env.PORT ?? 3000));
}
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
const config = configFromEnv();
const issueAt = process.argv.indexOf('--issue');
if (issueAt !== -1) {
process.stdout.write(issueFeedbackToken(config, process.argv[issueAt + 1] ?? 'user-1'));
} else {
serve(config);
}
}
node/webhook-receiver.mjs// Innspel — webhook receiver in Node (no dependencies, Node 24).
//
// Innspel never sends push and never holds push tokens: it tells YOUR server that
// something happened, and you notify the user. The payload carries IDs, the
// pseudonym and the status — never the submission text, never a screenshot.
//
// This file is complete and runnable:
// node webhook-receiver.mjs → HTTP server on $PORT (default 3000)
// node webhook-receiver.mjs --verify → reads {secret, header, body, now?} as JSON on stdin,
// prints "valid" or "invalid" (used by Innspel's CI)
//
// Configuration (environment):
// INNSPEL_WEBHOOK_SECRET shown ONCE when the webhook is created or rotated, prefix included
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
/** Innspel rejects anything older or newer than this; so should you — a valid signature on a stale request is a replay. */
export const TOLERANCE_SECONDS = 5 * 60;
/**
* Verify `X-Innspel-Signature` (`t=<unix seconds>,v1=<hex HMAC-SHA256>`) over the raw body.
*
* `rawBody` is the body EXACTLY as received (Buffer or string) — not an object you parsed
* and re-serialised. One space more or less gives a different signature. The key is the
* secret's characters as you pasted them, `innspel_whsec_` prefix included; no decoding.
*/
export function verifySignature(rawBody, header, secret, nowSeconds = Date.now() / 1000) {
const parts = Object.fromEntries(String(header ?? '').split(',').map((p) => p.split('=', 2)));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(nowSeconds - t) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
const actual = Buffer.from(parts.v1 ?? '', 'hex');
// Constant-time: `===` on hex strings leaks how many characters matched.
return expected.length === actual.length && timingSafeEqual(expected, actual);
}
/** Your own storage. Mark an event handled only AFTER handling succeeded (see below). */
const handled = new Set();
async function handle(event) {
// Do the real work here — e.g. notify the user whose pseudonym is event.data.subject.
// Keep it fast or hand it to a queue: Innspel waits 10 seconds for your 2xx.
}
function serve(secret) {
const server = createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== '/webhooks/innspel') {
res.writeHead(404).end();
return;
}
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const raw = Buffer.concat(chunks);
if (!verifySignature(raw, req.headers['x-innspel-signature'], secret)) {
res.writeHead(401).end();
return;
}
const event = JSON.parse(raw.toString('utf8'));
// "Verify" in the panel: answer with the challenge. Nothing is delivered until you do.
if (event.type === 'webhook.verify') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ challenge: event.data.challenge }));
return;
}
// "Send test" in the panel: one attempt, no retry, nothing to do.
if (event.type === 'webhook.test') {
res.writeHead(200).end();
return;
}
// Idempotent on the event ID (also in X-Innspel-Event-Id): the same event may arrive again.
// A duplicate you already handled gets 2xx — that is what stops the retries.
if (!handled.has(event.id)) {
try {
await handle(event);
handled.add(event.id); // only now: marking on receipt would lose a failed attempt
} catch {
res.writeHead(500).end(); // Innspel retries: after 1 min, 5 min, 30 min and 2 h
return;
}
}
res.writeHead(200).end();
});
server.listen(Number(process.env.PORT ?? 3000));
}
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
if (process.argv.includes('--verify')) {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
const input = JSON.parse(Buffer.concat(chunks).toString('utf8'));
const ok = verifySignature(input.body, input.header, input.secret, input.now ?? Date.now() / 1000);
process.stdout.write(ok ? 'valid' : 'invalid');
} else {
const secret = process.env.INNSPEL_WEBHOOK_SECRET;
if (!secret) throw new Error('INNSPEL_WEBHOOK_SECRET is not set');
serve(secret);
}
}
Examples: .NET
.NET 10, one minimal-API project: the two classes and the program that wires them to /api/innspel-token and /webhooks/innspel.
dotnet/Innspel.Examples/FeedbackTokens.csusing System.Security.Cryptography;
using System.Text;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
namespace Innspel.Examples;
/// <summary>
/// What the token endpoint needs. Tenant ID is under Organisation in the panel, product ID
/// under Products and SDK keys, and <c>KeyId</c> is the <c>kid</c> of the public key you
/// registered there. The private key never leaves your server; the pseudonym secret is
/// permanent — rotating it severs every user from their past submissions.
/// </summary>
public sealed record FeedbackTokenOptions(
string TenantId,
string ProductId,
string KeyId,
string PrivateKeyPem,
string PseudonymSecret);
/// <summary>
/// Issues the feedback token your app hands to the Innspel SDK. The rules Innspel enforces:
/// ES256 only, <c>kid</c> in the header, <c>aud</c> = <c>innspel</c>, <c>iss</c> = tenant ID,
/// <c>prd</c> = product ID, <c>sub</c> = pseudonym, <c>exp</c> at most 15 minutes after <c>iat</c>.
/// </summary>
public static class FeedbackTokens
{
public const string Audience = "innspel";
/// <summary>The maximum lifetime Innspel accepts.</summary>
public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(15);
private static readonly JsonWebTokenHandler Handler = new() { SetDefaultTimesOnTokenCreation = false };
/// <summary>Stable, irreversible pseudonym for a user. Use your internal user ID, never e-mail or phone.</summary>
public static string Pseudonym(string secret, string userId)
{
ArgumentException.ThrowIfNullOrEmpty(secret);
ArgumentException.ThrowIfNullOrEmpty(userId);
var mac = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(userId));
return Base64UrlEncoder.Encode(mac);
}
/// <summary>
/// Issue a token for <paramref name="userId"/>. <paramref name="segment"/> is optional and may
/// only carry attributes you allow-listed in the panel (Organisation → Segment keys).
/// </summary>
public static string Issue(FeedbackTokenOptions options, string userId, IReadOnlyDictionary<string, string>? segment = null, DateTimeOffset? now = null)
{
ArgumentNullException.ThrowIfNull(options);
var issuedAt = (now ?? DateTimeOffset.UtcNow).UtcDateTime;
using var ecdsa = ECDsa.Create();
ecdsa.ImportFromPem(options.PrivateKeyPem);
var key = new ECDsaSecurityKey(ecdsa) { KeyId = options.KeyId };
var claims = new Dictionary<string, object> { ["prd"] = options.ProductId };
if (segment is { Count: > 0 })
{
claims["seg"] = segment;
}
return Handler.CreateToken(new SecurityTokenDescriptor
{
Issuer = options.TenantId,
Audience = Audience,
Subject = new System.Security.Claims.ClaimsIdentity([new System.Security.Claims.Claim("sub", Pseudonym(options.PseudonymSecret, userId))]),
Claims = claims,
IssuedAt = issuedAt,
NotBefore = issuedAt,
Expires = issuedAt + Lifetime,
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256),
});
}
}
dotnet/Innspel.Examples/WebhookSignatures.csusing System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace Innspel.Examples;
/// <summary>
/// Verifies <c>X-Innspel-Signature</c> (<c>t=<unix seconds>,v1=<hex HMAC-SHA256></c>)
/// over the raw body. The key is the secret's characters as you pasted them,
/// <c>innspel_whsec_</c> prefix included — no decoding.
/// </summary>
public static class WebhookSignatures
{
public const string Header = "X-Innspel-Signature";
/// <summary>Innspel rejects anything older or newer than this; so should you — a valid signature on a stale request is a replay.</summary>
public static readonly TimeSpan Tolerance = TimeSpan.FromMinutes(5);
/// <param name="rawBody">The body EXACTLY as received — not an object you parsed and re-serialised.</param>
/// <param name="header">The value of <see cref="Header"/>.</param>
/// <param name="secret">The webhook secret, shown once when created or rotated.</param>
/// <param name="now">Your clock; defaults to UTC now.</param>
public static bool IsValid(string rawBody, string? header, string secret, DateTimeOffset? now = null)
{
ArgumentNullException.ThrowIfNull(rawBody);
ArgumentException.ThrowIfNullOrEmpty(secret);
if (string.IsNullOrEmpty(header))
{
return false;
}
long? timestamp = null;
string? signature = null;
foreach (var part in header.Split(','))
{
var eq = part.IndexOf('=', StringComparison.Ordinal);
if (eq < 0)
{
continue;
}
var name = part[..eq];
var value = part[(eq + 1)..];
if (name == "t" && long.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var t))
{
timestamp = t;
}
else if (name == "v1")
{
signature = value;
}
}
if (timestamp is not { } ts || signature is null)
{
return false;
}
var age = (now ?? DateTimeOffset.UtcNow) - DateTimeOffset.FromUnixTimeSeconds(ts);
if (age.Duration() > Tolerance)
{
return false;
}
var signed = quot;{ts.ToString(CultureInfo.InvariantCulture)}.{rawBody}";
var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(signed));
byte[] actual;
try
{
actual = Convert.FromHexString(signature);
}
catch (FormatException)
{
return false;
}
// Constant-time: a plain string comparison leaks how many characters matched.
return CryptographicOperations.FixedTimeEquals(expected, actual);
}
}
dotnet/Innspel.Examples/Program.cs// Innspel — token endpoint and webhook receiver in .NET (minimal API).
//
// `dotnet run` serves both. Configuration (environment):
// INNSPEL_TENANT_ID tenant ID — Organisation in the panel (`iss`)
// INNSPEL_PRODUCT_ID product ID — Products and SDK keys in the panel (`prd`)
// INNSPEL_KEY_ID `kid` of the key you registered
// INNSPEL_PRIVATE_KEY the PRIVATE key, EC P-256, PKCS#8 PEM — never leaves your server
// INNSPEL_PSEUDONYM_SECRET the HMAC secret behind the pseudonym — permanent
// INNSPEL_WEBHOOK_SECRET shown ONCE when the webhook is created or rotated, prefix included
using System.Text.Json;
namespace Innspel.Examples;
public static class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var tokenOptions = new FeedbackTokenOptions(
TenantId: Required("INNSPEL_TENANT_ID"),
ProductId: Required("INNSPEL_PRODUCT_ID"),
KeyId: Required("INNSPEL_KEY_ID"),
PrivateKeyPem: Required("INNSPEL_PRIVATE_KEY"),
PseudonymSecret: Required("INNSPEL_PSEUDONYM_SECRET"));
var webhookSecret = Required("INNSPEL_WEBHOOK_SECRET");
app.MapGet("/api/innspel-token", (HttpRequest request, HttpResponse response) =>
{
if (Authenticate(request) is not { } user)
{
return Results.Unauthorized(); // the SDK then submits anonymously instead of blocking
}
var token = FeedbackTokens.Issue(tokenOptions, user.Id, new Dictionary<string, string> { ["plan"] = user.Plan });
response.Headers.CacheControl = "no-store"; // never cache: the token is short-lived on purpose
return Results.Json(new { token });
});
// Your own storage. Mark an event handled only AFTER handling succeeded.
var handled = new HashSet<string>();
app.MapPost("/webhooks/innspel", async (HttpRequest request) =>
{
using var reader = new StreamReader(request.Body);
var raw = await reader.ReadToEndAsync(); // the body exactly as received
if (!WebhookSignatures.IsValid(raw, request.Headers[WebhookSignatures.Header], webhookSecret))
{
return Results.Unauthorized();
}
using var json = JsonDocument.Parse(raw);
var root = json.RootElement;
var type = root.GetProperty("type").GetString();
// "Verify" in the panel: answer with the challenge. Nothing is delivered until you do.
if (type == "webhook.verify")
{
return Results.Json(new { challenge = root.GetProperty("data").GetProperty("challenge").GetString() });
}
// "Send test" in the panel: one attempt, no retry, nothing to do.
if (type == "webhook.test")
{
return Results.Ok();
}
// Idempotent on the event ID (also in X-Innspel-Event-Id): the same event may arrive
// again. Add is false for a duplicate you already handled — answer 2xx without doing it
// again; that is what stops the retries.
var id = root.GetProperty("id").GetString()!;
if (handled.Add(id))
{
// Do the real work here — e.g. notify the user whose pseudonym is data.subject.
// Keep it fast or hand it to a queue: Innspel waits 10 seconds for your 2xx. If the
// work fails, remove the ID again and return 500 — Innspel retries after 1 min,
// 5 min, 30 min and 2 h, and an ID marked on receipt would make that retry a "duplicate".
}
return Results.Ok();
});
app.Run();
}
private static string Required(string name) =>
Environment.GetEnvironmentVariable(name) ?? throw new InvalidOperationException(quot;{name} is not set");
/// <summary>Replace with your own session check. Null means not signed in.</summary>
private static (string Id, string Plan)? Authenticate(HttpRequest request)
{
var session = request.Headers.Authorization.ToString().Replace("Bearer ", "", StringComparison.Ordinal);
return string.IsNullOrEmpty(session) ? null : (session, "standard"); // look the session up in your own store
}
}
The packages
`@innspel/core`, `@innspel/react-native` and `@innspel/web` on npm.