Aller au contenu principal

Authentication

SSP uses four distinct credentials. Using the wrong one is the most common integration error, so start here.

The four credentials​

CredentialFormatHeaderWho holds itLifetime
Portal sessionJWTAuthorization: Bearer <jwt>You, in the browser4 hours, no refresh
Developer keyssp_dev_ + 32 chars (40 total)Authorization: Bearer ssp_dev_…Your CI/scriptsUntil revoked
Installation keypik_ + 64 hex (68 total)X-Plugin-Api-Key: pik_…Your running pluginUntil regenerated
Webhook secret64 chars(HMAC key, never transmitted)Your running pluginUntil 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/v1 route, 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​

ErrorHTTPCause
missing_api_key401No X-Plugin-Api-Key header
invalid_api_key_format401Doesn't start with pik_, or isn't 68 characters
invalid_api_key401No installation matches the key
installation_inactive403The installation's status isn't active
plugin_listing_unavailable403Production only — the listing isn't approved/active
sandbox_not_ready401Sandbox isn't active
sandbox_resetting409A sandbox reset is in flight — retry shortly
rate_limit_exceeded429Over 60/min — honour Retry-After
plugin_listing_unavailable is deliberately status-neutral

The 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​

MomentWhat you get
Sandbox provisioningReturned once, alongside the pik_ key
Creating a plugin from a projectA new secret, returned once
Rotate Secret in the portalA 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.

  1. Rotate in the portal, copy the new plaintext.
  2. Deploy it and restart your service.
  3. 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))
}
Always compare in constant time

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',
}},
);
Hash the string, then send that same string

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 } }"}'
PropertyValue
Scopesplugins:read, plugins:write
Active cap10 per account
Rate limit60 requests/minute per key
IP allowlistOptional — 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.

ErrorHTTPCause
authentication_failed401Unknown, expired, or revoked key
ip_not_allowed403Source IP outside the key's allowlist
rate_limit_exceeded429Over 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"
attention

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​