Verified Actions
Your app answers one question — did this user do X? — with a cryptographically signed yes or no. Giveaway tasks become deterministic instead of honor-system checkboxes.
The whole spec in agent-ready form — paste it into Claude Code, Cursor, or any AI assistant.
A four-step signed handshake
No polling, no webhooks, no shared database. The participant's browser carries two signed tokens between us and you.
ScopliDrop redirects the participant to you
When a giveaway entrant hits your task, we redirect their browser to your registered Verify URL with a signed request token on the query string.
You verify the request token
Check the signature (pin the algorithm), and confirm the issuer is scoplidrop.com, the audience is your client_id, and it has not expired.
You authenticate your own user and check the action
Use your own login/session. If there is no session, show your login and resume after. Then look up whether that user completed the action.
You redirect back with a signed yes/no
Sign a response token carrying your result and redirect the browser to our callback. ScopliDrop verifies it and marks the task pass or fail.
Register your app
Verified Actions is an Enterprise-plan feature. Registration takes a minute and gives you everything you need to sign and verify.
- 1
In the ScopliDrop dashboard, open Developer → Verified Actions and create an app.
- 2
Set your Verify URL (where we redirect participants), pick a signing mode, and declare one or more actions your giveaway tasks can reference.
- 3
Receive your client_id and, in HMAC mode, your shared secret (vas_…) — shown once. In public-key mode, register your Ed25519 public key instead.
- 4
Implement the verify-and-respond endpoint below. Test it end to end with the local demo partner in scripts/demo-verified-app/.
Token claims
Both tokens are standard JWTs. Verify the request; sign the response.
Request token — ScopliDrop to you
Arrives as ?token=<JWT> on your Verify URL. HMAC mode is signed HS256 with your shared secret; public-key mode is signed EdDSA and verified against our JWKS.
| Claim | Value | Notes |
|---|---|---|
| iss | "scoplidrop.com" | Our identity. Always verify this. |
| aud | your client_id | Confirm it matches your registered client_id. |
| sub | opaque user id | A correlation handle for this participant. NOT proof of identity — never look it up. |
| action | action id | The action the giveaway host attached to the task. |
| jti | handshake id | Unique per handshake. Echo it back as req. |
| iat / exp | timestamps | Issued-at and expiry (+15 min). |
Response token — you to ScopliDrop
Delivered by redirecting the browser to our callback. HMAC mode is signed HS256 with the same secret; public-key mode is signed EdDSA with your own private key.
| Claim | Value | Notes |
|---|---|---|
| iss | your client_id | Your identity as the responder. |
| aud | "scoplidrop.com" | Address the response to us. |
| sub | the same sub | Echo back the sub you received, unchanged. |
| req | the request jti | The jti from the request token. Ties the answer to the handshake. |
| result | true | false | The deterministic yes/no. |
| code | optional, ≤64 chars | A short failure code shown to the entrant on a false result. |
| puid | optional, ≤128 chars | A stable id for your user. Recommended for future dedup. |
| iat / exp | timestamps | Issued-at and expiry (+5 min recommended; we reject anything older than 10 min from iat). |
Verify and respond
A complete Node handler using jose. HMAC is the default path; the public-key variant differs only in how you verify and sign.
HMAC mode (default)
import { createServer } from 'node:http';
import { SignJWT, jwtVerify } from 'jose';
// Your shared secret (vas_...), shown once at registration. The STRING is the
// key material — its UTF-8 bytes go straight into jose.
const SECRET = new TextEncoder().encode(process.env.SCOPLI_SECRET);
const CLIENT_ID = 'your-client-id';
createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
if (url.pathname !== '/scopli-verify') return res.end();
// 1. Verify the request token. Pin HS256; require our iss and your aud.
const { payload } = await jwtVerify(url.searchParams.get('token'), SECRET, {
issuer: 'scoplidrop.com',
audience: CLIENT_ID,
algorithms: ['HS256'],
});
// 2. Authenticate YOUR user (your session) and check the action.
// payload.sub is only a correlation handle — never look it up as identity.
const user = await getLoggedInUser(req); // your auth
if (!user) return redirectToLogin(res, url); // keep the token, resume after
const didIt = await userCompletedAction(user, payload.action);
// 3. Sign the yes/no and redirect back to ScopliDrop's callback.
const response = await new SignJWT({ req: payload.jti, result: didIt, puid: user.id })
.setProtectedHeader({ alg: 'HS256' })
.setIssuer(CLIENT_ID)
.setAudience('scoplidrop.com')
.setSubject(payload.sub) // echo the sub back unchanged
.setIssuedAt()
.setExpirationTime('5m')
.sign(SECRET);
res.writeHead(302, {
Location: 'https://www.scoplidrop.com/api/verify/external/callback?response=' + response,
});
res.end();
}).listen(4102);Public-key mode (EdDSA / JWKS)
Same flow; swap the verify key and signing key. Everything else in the handler above is unchanged.
import { SignJWT, jwtVerify, createRemoteJWKSet, importPKCS8 } from 'jose';
// Public-key mode: verify our request tokens against our published Ed25519 keys.
const scopliJwks = createRemoteJWKSet(
new URL('https://www.scoplidrop.com/.well-known/jwks.json'),
);
const { payload } = await jwtVerify(token, scopliJwks, {
issuer: 'scoplidrop.com',
audience: CLIENT_ID,
algorithms: ['EdDSA'],
});
// Sign your response with YOUR Ed25519 private key (register the public half
// as a PEM or a JWKS URL when you create the app).
const privateKey = await importPKCS8(process.env.MY_ED25519_PRIVATE_PEM, 'EdDSA');
const response = await new SignJWT({ req: payload.jti, result: didIt })
.setProtectedHeader({ alg: 'EdDSA' })
.setIssuer(CLIENT_ID)
.setAudience('scoplidrop.com')
.setSubject(payload.sub)
.setIssuedAt()
.setExpirationTime('5m')
.sign(privateKey);Security requirements
- Verify the signature, iss, aud, and exp on every request token. Pin the algorithm — never let the token header choose it.
- Answer only for YOUR authenticated user. The sub claim is a correlation handle, not an identity you can look up.
- Return a stable puid for your user — it lets ScopliDrop dedup entrants across handshakes later.
- Each handshake is one-shot: a given req can be answered exactly once. Replays are rejected.
- Process the request token within 15 minutes. It survives a login/signup detour, but not longer.
Every rejection has a precise code
While integrating, open your app in the ScopliDrop dashboard (Developer → Verified Actions → Handshakes): every Verify click appears there with its outcome. Rejected answers show one of the codes below — the same code also rides the error redirect as the message parameter.
| Code | What it means | Fix |
|---|---|---|
| invalid_signature | Your signature does not verify. | Shared-secret apps: you are signing with a different secret than the one registered (rotate it if lost). Public-key apps: the registered public key does not match your private key. |
| wrong_algorithm | The token alg is not the one your signing mode pins. | Shared-secret apps must sign HS256; public-key apps must sign EdDSA (Ed25519). |
| wrong_issuer | The iss claim is not your client_id. | Set iss to your exact client_id as shown in the dashboard. |
| wrong_audience | The aud claim is wrong. | Set aud to the literal string scoplidrop.com. |
| response_expired | The exp claim has passed. | Mint the response token immediately before redirecting; set exp to about +5 minutes. |
| response_too_old | The iat claim is older than 10 minutes. | Never cache or queue response tokens — mint fresh per answer. |
| missing_claim_req | The req claim is absent or not a string. | Echo the jti from the request token as req. |
| missing_claim_result | The result claim is absent or not a boolean. | Send result: true or result: false — a real boolean, not a string. |
| missing_claim_sub | The sub claim is absent. | Echo the sub from the request token unchanged. |
| unknown_client_id | No active app is registered under the iss you sent. | Check the client_id and that the app is not deactivated. |
| unknown_handshake | The req does not match any handshake we minted. | Echo our jti exactly; handshakes also disappear ~15 minutes after they expire. |
| rejected_handshake_expired | You answered more than 15 minutes after the redirect. | The participant simply retries — answer within the request token lifetime. |
| subject_mismatch / issuer_mismatch | Your echoed sub (or your iss) does not match the handshake. | Answer each request with its own sub/jti — never mix handshakes. |
| no_matching_key | Your JWKS URL has no key matching the token kid. | Serve the signing key in your JWKS, or set a kid header that matches. |
| app_misconfigured | Public-key app with no key material registered. | Add a public key PEM or a JWKS URL to the app in the dashboard. |
Tip: run the reference partner locally and diff against your implementation — a known-good answer next to yours isolates the failing claim in seconds.
ScopliDrop reference
https://www.scoplidrop.com/api/verify/external/callbackhttps://www.scoplidrop.com/.well-known/jwks.jsonFor the host-facing side of this integration, the App Verified tasks feature page shows what hosts and entrants see, from app registration to the completed task.