ScopliDrop Developer Docs

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.

One endpoint on your sideStandard signed JWTsShips in an afternoon
view raw

The whole spec in agent-ready form — paste it into Claude Code, Cursor, or any AI assistant.

How it works

A four-step signed handshake

No polling, no webhooks, no shared database. The participant's browser carries two signed tokens between us and you.

1

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.

2

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.

3

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.

4

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.

Getting started

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. 1

    In the ScopliDrop dashboard, open Developer → Verified Actions and create an app.

  2. 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. 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. 4

    Implement the verify-and-respond endpoint below. Test it end to end with the local demo partner in scripts/demo-verified-app/.

Reference

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.

ClaimValueNotes
iss"scoplidrop.com"Our identity. Always verify this.
audyour client_idConfirm it matches your registered client_id.
subopaque user idA correlation handle for this participant. NOT proof of identity — never look it up.
actionaction idThe action the giveaway host attached to the task.
jtihandshake idUnique per handshake. Echo it back as req.
iat / exptimestampsIssued-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.

ClaimValueNotes
issyour client_idYour identity as the responder.
aud"scoplidrop.com"Address the response to us.
subthe same subEcho back the sub you received, unchanged.
reqthe request jtiThe jti from the request token. Ties the answer to the handshake.
resulttrue | falseThe deterministic yes/no.
codeoptional, ≤64 charsA short failure code shown to the entrant on a false result.
puidoptional, ≤128 charsA stable id for your user. Recommended for future dedup.
iat / exptimestampsIssued-at and expiry (+5 min recommended; we reject anything older than 10 min from iat).
Implementation

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.
Debugging

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.

CodeWhat it meansFix
invalid_signatureYour 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_algorithmThe token alg is not the one your signing mode pins.Shared-secret apps must sign HS256; public-key apps must sign EdDSA (Ed25519).
wrong_issuerThe iss claim is not your client_id.Set iss to your exact client_id as shown in the dashboard.
wrong_audienceThe aud claim is wrong.Set aud to the literal string scoplidrop.com.
response_expiredThe exp claim has passed.Mint the response token immediately before redirecting; set exp to about +5 minutes.
response_too_oldThe iat claim is older than 10 minutes.Never cache or queue response tokens — mint fresh per answer.
missing_claim_reqThe req claim is absent or not a string.Echo the jti from the request token as req.
missing_claim_resultThe result claim is absent or not a boolean.Send result: true or result: false — a real boolean, not a string.
missing_claim_subThe sub claim is absent.Echo the sub from the request token unchanged.
unknown_client_idNo active app is registered under the iss you sent.Check the client_id and that the app is not deactivated.
unknown_handshakeThe req does not match any handshake we minted.Echo our jti exactly; handshakes also disappear ~15 minutes after they expire.
rejected_handshake_expiredYou answered more than 15 minutes after the redirect.The participant simply retries — answer within the request token lifetime.
subject_mismatch / issuer_mismatchYour echoed sub (or your iss) does not match the handshake.Answer each request with its own sub/jti — never mix handshakes.
no_matching_keyYour JWKS URL has no key matching the token kid.Serve the signing key in your JWKS, or set a kid header that matches.
app_misconfiguredPublic-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.

Endpoints

ScopliDrop reference

Callback (redirect the browser here with your response)
https://www.scoplidrop.com/api/verify/external/callback
JWKS (verify our EdDSA request tokens)
https://www.scoplidrop.com/.well-known/jwks.json

For 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.