Authentication
SSP uses four distinct credentials. Using the wrong one is the most common integration error, so start here.
The four credentials
| Credential | Format | Header | Who holds it | Lifetime |
|---|---|---|---|---|
| Portal session | JWT | Authorization: Bearer <jwt> | You, in the browser | 4 hours, no refresh |
| Developer key | ssp_dev_ + 32 chars (40 total) | Authorization: Bearer ssp_dev_… | Your CI/scripts | Until revoked |
| Installation key | pik_ + 64 hex (68 total) | X-Plugin-Api-Key: pik_… | Your running plugin | Until regenerated |
| Webhook secret | 64 chars | (HMAC key, never transmitted) | Your running plugin | Until rotated |
Control plane vs data plane. Portal sessions and ssp_dev_ keys manage
things about your plugin. pik_ keys and the webhook secret are what your
running plugin uses to move restaurant data. They never substitute for each
other — a portal JWT is rejected by the Manager/Waiter/Kitchen apps, and a
pik_ key means nothing to the portal.
Plugin → SSP: the installation key
Every plugin installation — production or sandbox — gets its own key:
pik_ + 64 hex characters = 68 characters total
pik_a3f200001234567890abcdef1234567890abcdef1234567890abcdef12345678
Send it on every Plugin Data API request:
curl "https://api.ssppos.com/api/plugin/v1/orders" \
-H "X-Plugin-Api-Key: pik_YOUR_KEY_HERE"
Key facts
- Per installation, not per plugin. A plugin installed at 40 restaurants has 40 distinct keys. Store them keyed by installation.
- Shown once. At sandbox provisioning, at setup-token exchange, or on regeneration. Never retrievable afterwards.
- Regeneration revokes immediately — there is no overlap window.
- The same key routes to sandbox or production depending on the installation. Your code needs no environment branch.
- 60 requests/minute, per installation — enforced on every
plugin/v1route, inbound webhook forwarding included. (A second 600/min ceiling exists on the webhook route, but the 60/min gate is checked first, so 60 is the effective limit and forwarded webhooks share your data-API budget.)
Authentication failures
| Error | HTTP | Cause |
|---|---|---|
missing_api_key | 401 | No X-Plugin-Api-Key header |
invalid_api_key_format | 401 | Doesn't start with pik_, or isn't 68 characters |
invalid_api_key | 401 | No installation matches the key |
installation_inactive | 403 | The installation's status isn't active |
plugin_listing_unavailable | 403 | Production only — the listing isn't approved/active |
sandbox_not_ready | 401 | Sandbox isn't active |
sandbox_resetting | 409 | A sandbox reset is in flight — retry shortly |
rate_limit_exceeded | 429 | Over 60/min — honour Retry-After |
plugin_listing_unavailable is deliberately status-neutralThe same code covers suspended, deprecated, rejected, still-under-review, and deleted listings. It never tells a caller which — the reason is in SSP's logs, not the response. Sandbox installations are exempt from this check entirely.
The webhook secret
One secret signs traffic in both directions:
- SSP → you (outbound): SSP signs the payload and sends the hex digest in
X-SSP-Signature. You verify it. - You → SSP (inbound): you sign your request body with the same secret and
send the digest in
X-SSP-Signature. SSP verifies it.
Where to get it
| Moment | What you get |
|---|---|
| Sandbox provisioning | Returned once, alongside the pik_ key |
| Creating a plugin from a project | A new secret, returned once |
| Rotate Secret in the portal | A new secret, returned once |
It is never queryable. If you lose it, rotate — the old one is invalidated.
Rotating safely
Rotation is immediate and has no overlap window, so any in-flight delivery signed with the old secret will fail your verification.
- Rotate in the portal, copy the new plaintext.
- Deploy it and restart your service.
- Fire a test webhook from the portal and confirm it verifies.
Store it as, for example, SSP_WEBHOOK_SECRET in your environment.
Verifying SSP → your plugin
SSP sends:
POST /webhooks/ssp HTTP/1.1
Content-Type: application/json
X-SSP-Event: order.created
X-SSP-Signature: a1b2c3… (hex HMAC-SHA256 of the raw body)
The signature covers the exact bytes on the wire. If your framework parses the body before your handler sees it, re-serializing will produce different bytes and verification will fail.
Node.js (Express)
const crypto = require('crypto');
function verify(rawBody, receivedHex, secret) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(receivedHex || '', 'utf8');
const b = Buffer.from(expected, 'utf8');
// timingSafeEqual throws on length mismatch — check first.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/webhooks/ssp', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req.body, req.headers['x-ssp-signature'], process.env.SSP_WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
res.json({ status: 'ok' }); // answer fast; process asynchronously
});
Python (Flask)
import hmac, hashlib, os
from flask import request, jsonify
@app.route('/webhooks/ssp', methods=['POST'])
def ssp_webhook():
expected = hmac.new(
os.environ['SSP_WEBHOOK_SECRET'].encode(),
request.get_data(), # raw bytes, NOT request.json
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(request.headers.get('X-SSP-Signature', ''), expected):
return jsonify({'error': 'invalid signature'}), 401
event = request.get_json()
return jsonify({'status': 'ok'})
PHP
$raw = file_get_contents('php://input');
$expected = hash_hmac('sha256', $raw, $_ENV['SSP_WEBHOOK_SECRET']);
if (!hash_equals($expected, $_SERVER['HTTP_X_SSP_SIGNATURE'] ?? '')) {
http_response_code(401);
exit(json_encode(['error' => 'invalid signature']));
}
$event = json_decode($raw, true);
Go
func verify(rawBody []byte, received, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(received), []byte(expected))
}
Use crypto.timingSafeEqual, hmac.compare_digest, hash_equals, or
hmac.Equal — never ==. A byte-by-byte comparison leaks how much of the
signature you got right.
Signing your plugin → SSP
To forward a provider event to SSP, sign the exact body you transmit:
const body = JSON.stringify({
event_type: 'external.payment_confirmed',
payload: { transaction_id: 'pay_123' },
idempotency_key: 'evt-001',
});
const signature = crypto
.createHmac('sha256', process.env.SSP_WEBHOOK_SECRET)
.update(body)
.digest('hex');
await axios.post(
'https://api.ssppos.com/api/plugin/v1/webhooks/external',
body, // send the string you hashed
{ headers: {
'X-Plugin-Api-Key': process.env.SSP_PLUGIN_API_KEY,
'X-SSP-Signature': signature,
'Content-Type': 'application/json',
}},
);
Passing an object to your HTTP client lets it re-serialize with different key order or spacing than what you hashed. Serialize once, hash that, send that.
Inbound requests need both headers: X-Plugin-Api-Key identifies the
installation, X-SSP-Signature proves the body wasn't tampered with. A bad
signature returns 401 invalid_signature and is recorded in the audit log with
signature_valid: false.
Developer keys (ssp_dev_) for CI
Developer keys authenticate the portal's GraphQL surface — projects, plugins, sandboxes — from scripts:
curl https://api.ssppos.com/graphql \
-H "Authorization: Bearer ssp_dev_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"{ myDeveloperProjects { id name } }"}'
| Property | Value |
|---|---|
| Scopes | plugins:read, plugins:write |
| Active cap | 10 per account |
| Rate limit | 60 requests/minute per key |
| IP allowlist | Optional — a blocked source gets 403 ip_not_allowed |
Key management itself — minting, listing, revoking — and accepting the developer agreements are portal-session-only. A key can't mint another key. Full detail: Developer Account.
| Error | HTTP | Cause |
|---|---|---|
authentication_failed | 401 | Unknown, expired, or revoked key |
ip_not_allowed | 403 | Source IP outside the key's allowlist |
rate_limit_exceeded | 429 | Over the key's per-minute limit |
Storing credentials
Never hardcode. Use environment variables or a secret manager:
# .env — never commit
SSP_PLUGIN_API_KEY=pik_... # per installation
SSP_WEBHOOK_SECRET=... # 64 chars
SSP_DEV_KEY=ssp_dev_... # CI only
# Kubernetes
apiVersion: v1
kind: Secret
metadata:
name: ssp-plugin-secrets
type: Opaque
stringData:
SSP_PLUGIN_API_KEY: pik_...
SSP_WEBHOOK_SECRET: ...
For a multi-tenant plugin, pik_ keys are per installation — store them in
your database against the installation ID, not in a single environment
variable. You receive each one either at sandbox provisioning or through the
setup-token exchange.
OAuth 2.0 with third-party providers
Some integrations (QuickBooks, Uber Eats, Google) need OAuth against the provider. That handshake goes through the SSP backend, not your plugin:
When registering your plugin, set integration_type: oauth2 and supply your
client ID, client secret (stored encrypted), and scopes. Register
https://api.ssppos.com/api/v1/oauth/callback as an allowed redirect URI with
your provider.
Your plugin reads the resulting tokens from its installation config:
curl "https://api.ssppos.com/api/plugin/v1/installation" \
-H "X-Plugin-Api-Key: pik_YOUR_KEY"
Never log or surface refresh tokens. SSP refreshes access tokens on your behalf.
Troubleshooting
"The key is correct but I get 401." Check for a trailing newline from
echo, whitespace in the env var, or the wrong header name — it's
X-Plugin-Api-Key, not Authorization: Bearer.
"Signature verification fails intermittently." Almost always a raw-body problem: a body-parsing middleware is consuming the stream before your handler. Mount raw parsing on the webhook route specifically.
"Works locally, fails in production." Your webhook_endpoint must be a
public https URL. SSP refuses to deliver to private, loopback, link-local,
or reserved addresses, and does not follow redirects.
"Health checks are getting 401s." GET /health still requires a valid
pik_ key. Your own load-balancer health check should hit your endpoint, not
SSP's.
Next steps
- Plugin Data API — what you can read and write
- Webhooks — the event catalog
- Setup Handoff — receiving
pik_keys at install time - Security — hardening beyond authentication