Docs
palmyn.com v1.0

Palmyn Developer Documentation

Palmyn is a biometric identity platform that lets you protect your site from bots and authenticate real human users — verified by their hand scan, not a password.

There are two products you can integrate independently or together:

🛡️
Palmyn Captcha
Drop-in widget that scores user behavior and, when needed, asks them to scan their hand via the Palmyn app. Returns a token your backend can verify.
🖐️
Login with Palmyn
Identity button that returns a verified human_id and email — a frictionless biometric login without passwords or SMS codes.
💡

One backend, two products. Captcha and Login share the same verify flow and token format — the only difference is what your backend does with the verified token. Use the drop-in captcha.js widget for plain bot protection, or the programmatic palmyn-auth.js widget for Login with Palmyn and silent auth.

Quickstart

Get Palmyn Captcha running on your site in under 5 minutes.

1
Get your keys

Palmyn is in active development. Public self-service registration isn't open yet. To get a test site_key / secret_key pair for an integration trial, email us:

✉️

Write to admin@palmyn.com with:
• Your domain (e.g. miapp.com)
• What you want to integrate (captcha, login, or both)
• A short note about the project

We reply within 1–2 business days with your key pair and any setup specifics. During the trial there's no charge and you get direct support from the team.

⚠️

Once you receive your keys, keep secret_key private — never expose it in frontend code. Store it as an environment variable on your server. The site_key is safe to embed in the widget.

2
Add the widget to your page

Drop in the widget script and a container. It renders a “No soy un robot” checkbox and scores behavior invisibly — most users pass without any extra step.

html
<form id="my-form">
  <input type="hidden" name="palmyn_token" id="captcha-token" />

  <!-- auto-renders on any .palmyn-captcha element -->
  <div class="palmyn-captcha"
       data-sitekey="pk_live_a1b2c3d4e5f6..."
       data-callback="onPalmynVerified"></div>

  <button type="submit">Submit</button>
</form>

<!-- auto-inits all .palmyn-captcha elements on load -->
<script src="https://palmyn.com/api/widget/captcha.js" async></script>
<script>
  function onPalmynVerified(token) {
    // token travels with your form to your backend
    document.getElementById('captcha-token').value = token;
  }
</script>
3
Verify the token on your server

When your form is submitted, verify the token using your secret_key. Never trust the frontend alone.

$token     = $_POST['palmyn_token'] ?? '';
$secretKey = getenv('PALMYN_SECRET_KEY'); // sk_live_...

$response = file_get_contents(
    'https://palmyn.com/api/captcha.php'
    . '?q=siteverify'
    . '&secret=' . urlencode($secretKey)
    . '&token='  . urlencode($token)
);

$data = json_decode($response, true);

if (!$data['success']) {
    http_response_code(403);
    die('Verification failed: ' . implode(', ', $data['error-codes'] ?? []));
}

// $data['score'] — float 0.0–1.0 (confidence of being human)
// Continue processing the form...
echo 'Verified! Score: ' . $data['score'];
const https = require('https');

async function verifyPalmyn(token) {
  const secret = process.env.PALMYN_SECRET_KEY; // sk_live_...
  const url = `https://palmyn.com/api/captcha.php`
            + `?q=siteverify&secret=${encodeURIComponent(secret)}`
            + `&token=${encodeURIComponent(token)}`;

  const res  = await fetch(url);
  const data = await res.json();

  if (!data.success) {
    throw new Error('Palmyn verification failed: ' + (data['error-codes'] || []).join(', '));
  }
  return data; // { success, score, hostname, challenge_ts }
}

// Express route example
app.post('/submit', async (req, res) => {
  try {
    const result = await verifyPalmyn(req.body.palmyn_token);
    console.log('Score:', result.score);
    res.json({ ok: true });
  } catch (err) {
    res.status(403).json({ error: err.message });
  }
});
import os, requests

def verify_palmyn(token: str) -> dict:
    secret = os.environ["PALMYN_SECRET_KEY"]  # sk_live_...
    r = requests.get(
        "https://palmyn.com/api/captcha.php",
        params={"q": "siteverify", "secret": secret, "token": token},
        timeout=5,
    )
    data = r.json()
    if not data.get("success"):
        codes = ", ".join(data.get("error-codes", []))
        raise ValueError(f"Palmyn verification failed: {codes}")
    return data  # { "success": True, "score": 0.95, ... }

# Flask example
from flask import request, abort

@app.route("/submit", methods=["POST"])
def submit():
    result = verify_palmyn(request.form.get("palmyn_token", ""))
    print("Score:", result["score"])
    return {"ok": True}

That's it! Your site is now protected. Continue reading to understand all the options available.

Register a Site

Every domain that embeds the Palmyn widget needs a registered key pair. Each registered site gets:

KeyWhere it's usedKeep secret?
site_key pk_live_... Passed to the widget in your HTML — visible to users No — it's public
secret_key sk_live_... Used only in your backend to call siteverify Yes — never expose
✉️

How to get your keys (development phase).
Palmyn is still in private beta. Self-service registration isn't open yet.
To request a test pair for an integration trial, email admin@palmyn.com with:

  • The domain you want to register (e.g. miapp.com)
  • Whether you'll use Captcha, Login with Palmyn, or both
  • Optional: a redirect_uri if you're integrating Login with Palmyn (e.g. https://miapp.com/auth/palmyn/callback)
  • Short description of the project

We reply within 1–2 business days with your site_key + secret_key. The trial has no charge and you get direct support from the team.

Tiers

TierDescription
basicStandard behavior-based scoring + QR challenge when needed.
verifiedRequires hand registration — only users with verified_level: 2 can pass.

Tell us which tier you need in your registration email.

Captcha — How it works

Captcha flow diagram
🧠

The widget invisibly tracks mouse movement, keyboard timing, and scroll patterns before the user even clicks. Most legitimate users pass automatically without ever seeing a QR code.

Install the Widget

There are two widgets you can use. Both call the same backend and return the same kind of token — pick whichever fits your stack.

Option A — Drop-in widget recommended

A declarative, reCAPTCHA-style checkbox. Add the captcha.js script once and a container with data-* attributes — it auto-renders and runs behavior scoring invisibly. No JavaScript wiring required.

index.html
<form id="my-form">
  <!-- your form fields -->
  <input type="hidden" id="palmyn-token" name="palmyn_token" />

  <!-- auto-renders on any .palmyn-captcha element -->
  <div class="palmyn-captcha"
       data-sitekey="pk_live_..."
       data-callback="onPalmynVerified"
       data-theme="light"></div>

  <button type="submit">Submit</button>
</form>

<script src="https://palmyn.com/api/widget/captcha.js" async></script>
<script>
  function onPalmynVerified(token) {
    document.getElementById('palmyn-token').value = token;
  }
</script>

If you can't serve the widget from palmyn.com/api, point it at your backend with data-api on the div, or set window.__PALMYN_API_BASE__ before the script loads. You can also skip data-callback and listen for the palmyn:verified DOM event instead.

Option B — Programmatic widget

For SPAs, custom buttons, or silent auth, use the programmatic palmyn-auth.js widget. You control where it renders and get onSuccess / onError callbacks.

index.html
<form id="my-form">
  <input type="hidden" id="palmyn-token" name="palmyn_token" />
  <div id="palmyn-captcha"></div>
  <button type="submit" id="submit-btn" disabled>Submit</button>
</form>

<script src="https://palmyn.com/api/widget/palmyn-auth.js"></script>
<script>
  new PalmynAuth({
    siteKey:   'pk_live_...',
    container: '#palmyn-captcha',
    label:     'Verificar & Continuar',   // optional: customize button text

    onSuccess: function(token) {
      document.getElementById('palmyn-token').value = token;
      document.getElementById('submit-btn').disabled = false;
    },

    onError: function(reason) {
      // reason: 'blocked' | 'expired' | 'failed' | 'error'
      alert('Verification failed. Please try again.');
    },
  });
</script>
⚠️

Important: Always verify the token on your server using siteverify. Never trust the frontend callback alone — a user can forge it.

Verify the Token

After the widget returns a token (via the callback or onSuccess), your frontend sends it to your backend. Your backend calls siteverify with your secret_key.

Request

GET https://palmyn.com/api/captcha.php
  ?q=siteverify
  &secret=sk_live_...
  &token=eyJhb...

Response

200 OK — success
{
  "success":      true,
  "score":        0.97,
  "action":       "captcha",
  "hostname":     "miapp.com",
  "challenge_ts": "2026-05-26T14:32:10+00:00"
}

action reflects how the user passed: "captcha" (behavior), "palm_challenge" (hand scan via QR), or "silent_auth".

400 — failure
{
  "success":     false,
  "error-codes": ["invalid-input-response"]
}

Server-side verification

function palmyn_verify(string $token): array {
    $url = 'https://palmyn.com/api/captcha.php'
         . '?q=siteverify'
         . '&secret=' . urlencode(getenv('PALMYN_SECRET_KEY'))
         . '&token='  . urlencode($token);

    $ctx  = stream_context_create(['http' => ['timeout' => 5]]);
    $body = file_get_contents($url, false, $ctx);
    $data = json_decode($body, true);

    if (!($data['success'] ?? false)) {
        throw new RuntimeException('Palmyn: ' . implode(', ', $data['error-codes'] ?? ['unknown']));
    }

    return $data;
}

// Usage
try {
    $result = palmyn_verify($_POST['palmyn_token'] ?? '');
    // $result['score'] — 0.0 to 1.0
    // Process form...
} catch (RuntimeException $e) {
    http_response_code(403);
    exit($e->getMessage());
}
async function palmynVerify(token) {
  const url = new URL('https://palmyn.com/api/captcha.php');
  url.searchParams.set('q',      'siteverify');
  url.searchParams.set('secret', process.env.PALMYN_SECRET_KEY);
  url.searchParams.set('token',  token);

  const res  = await fetch(url.toString());
  const data = await res.json();

  if (!data.success) {
    throw new Error('Palmyn: ' + (data['error-codes'] || []).join(', '));
  }
  return data;
}

// Middleware example
async function requirePalmyn(req, res, next) {
  try {
    req.palmyn = await palmynVerify(req.body.palmyn_token || '');
    next();
  } catch (err) {
    res.status(403).json({ error: err.message });
  }
}

app.post('/submit', requirePalmyn, (req, res) => {
  console.log('Score:', req.palmyn.score);
  res.json({ ok: true });
});
import os
import requests
from functools import wraps
from flask import request, abort, g

PALMYN_API = "https://palmyn.com/api/captcha.php"

def palmyn_verify(token: str) -> dict:
    r = requests.get(
        PALMYN_API,
        params={
            "q":      "siteverify",
            "secret": os.environ["PALMYN_SECRET_KEY"],
            "token":  token,
        },
        timeout=5,
    )
    data = r.json()
    if not data.get("success"):
        codes = ", ".join(data.get("error-codes", ["unknown"]))
        raise ValueError(f"Palmyn: {codes}")
    return data

# Decorator for Flask routes
def require_palmyn(f):
    @wraps(f)
    def wrapper(*args, **kwargs):
        token = request.form.get("palmyn_token") or request.json.get("palmyn_token", "")
        g.palmyn = palmyn_verify(token)
        return f(*args, **kwargs)
    return wrapper

@app.route("/submit", methods=["POST"])
@require_palmyn
def submit():
    print("Score:", g.palmyn["score"])
    return {"ok": True}

Silent Auth

If a user has already completed a palm scan in your site recently (within 24 h), you can skip the QR challenge entirely by passing their Palmyn session token. The user is verified instantly.

🔒

Silent auth must be explicitly enabled for your site (set allow_silent_auth: 1 in your site registration). It's opt-in.

🧩

Silent auth is only available with the programmatic palmyn-auth.js widget — the drop-in captcha.js always runs a fresh check.

How to enable

While we're in development phase, ping us at admin@palmyn.com and we'll flip allow_silent_auth: 1 on your registered site. Once self-service registration is open, you'll be able to toggle it yourself.

How to use it

When you initialize the widget, pass requireFresh: false and the Palmyn session token you stored after a previous successful verification.

// The palmyn_session was stored after a previous login
const palmynSession = localStorage.getItem('palmyn_session');

new PalmynAuth({
  siteKey:       'pk_live_...',
  container:     '#palmyn-captcha',
  requireFresh:  false,           // allow silent auth
  palmynSession: palmynSession,   // optional — omit if none
  onSuccess: function(token) { /* verify on server */ },
});
⚠️

The palmyn_session token comes from the Palmyn app after the user completes a hand scan. It is not the secret_key — it is a short-lived token stored by the user's device.

Widget Configuration

Drop-in widget — captcha.js

Configure the declarative widget with data-* attributes on the .palmyn-captcha container.

AttributeDescription
data-sitekey required Your public site key pk_live_...
data-callback optional Name of a global function called with the token. Or listen for the palmyn:verified DOM event (event.detail.token).
data-theme optional "light" (default) or "dark"
data-api optional Backend base URL. Falls back to window.__PALMYN_API_BASE__, then https://palmyn.com/api

Programmatic widget — palmyn-auth.js

Pass these options to new PalmynAuth({ ... }).

OptionTypeDescription
siteKey string required Your public site key pk_live_...
container string | Element required CSS selector or DOM element where the button renders
onSuccess function(token) required Called with the verification token when the user passes
onError function(reason) optional Called with 'blocked', 'expired', or 'error'
label string optional Button label text. Default: "Continuar con Palmyn"
requireFresh boolean optional Default true. Set to false to allow silent auth.
palmynSession string | null optional Palmyn session token from a previous verification (enables silent auth)

Login with Palmyn — How it works

Login with Palmyn adds identity to the captcha flow. When a user passes verification, your backend receives not just a score — but a verified identity: a stable human_id, their email, and their verification level.

Login with Palmyn flow diagram
🔑

The human_id is the key concept. It's a stable, opaque identifier unique to each person — the same across all your services, but impossible to reverse-engineer back to their real identity.

Add the Login Button

The setup is identical to the captcha widget. Use a different label to make the intent clear:

index.html
<div id="palmyn-login"></div>

<script src="https://palmyn.com/api/widget/palmyn-auth.js"></script>
<script>
  new PalmynAuth({
    siteKey:   'pk_live_...',
    container: '#palmyn-login',
    label:     'Continuar con Palmyn',  // or your preferred label

    onSuccess: async function(token) {
      // Send to your backend
      const res = await fetch('/auth/palmyn/callback', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ token }),
      });

      const { sessionCookie, redirect } = await res.json();
      window.location.href = redirect || '/dashboard';
    },

    onError: function(reason) {
      document.getElementById('login-error').textContent =
        reason === 'blocked'
          ? 'Verification failed. Are you human? 🤔'
          : 'Something went wrong. Please try again.';
    },
  });
</script>

Verify & Get User Identity

The siteverify response for Login with Palmyn includes the user's identity fields in addition to the score.

200 OK — Login with Palmyn
{
  "success":        true,
  "human_id":       "hid_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
  "email":          "usuario@ejemplo.com",
  "verified_level": 2,
  "score":          0.97,
  "action":         "palm_challenge",
  "hostname":       "miapp.com",
  "challenge_ts":   "2026-05-26T14:32:10+00:00"
}

Backend handler

function palmyn_login(string $token): array {
    $url = 'https://palmyn.com/api/captcha.php'
         . '?q=siteverify'
         . '&secret=' . urlencode(getenv('PALMYN_SECRET_KEY'))
         . '&token='  . urlencode($token);

    $data = json_decode(file_get_contents($url), true);

    if (!($data['success'] ?? false)) {
        throw new RuntimeException('Verification failed');
    }

    $humanId       = $data['human_id'];        // "hid_..."
    $email         = $data['email'];            // "user@example.com"
    $verifiedLevel = (int)$data['verified_level']; // 0 | 1 | 2

    // Only allow users with biometric verification
    if ($verifiedLevel < 2) {
        throw new RuntimeException('Biometric verification required');
    }

    // Create or find user in YOUR database
    $user = DB::firstOrCreate(
        ['palmyn_id' => $humanId],
        ['email' => $email, 'name' => explode('@', $email)[0]]
    );

    return $user; // set your session cookie etc.
}

// Route handler
try {
    $user = palmyn_login($_POST['token'] ?? '');
    $_SESSION['user_id'] = $user['id'];
    header('Location: /dashboard');
} catch (RuntimeException $e) {
    http_response_code(403);
    echo json_encode(['error' => $e->getMessage()]);
}
// routes/auth.js
app.post('/auth/palmyn/callback', async (req, res) => {
  try {
    const url = new URL('https://palmyn.com/api/captcha.php');
    url.searchParams.set('q',      'siteverify');
    url.searchParams.set('secret', process.env.PALMYN_SECRET_KEY);
    url.searchParams.set('token',  req.body.token || '');

    const data = await fetch(url.toString()).then(r => r.json());

    if (!data.success) throw new Error('Verification failed');

    const { human_id, email, verified_level } = data;

    // Only allow biometric-verified users
    if (verified_level < 2) {
      return res.status(403).json({ error: 'Biometric verification required' });
    }

    // Upsert user in your DB
    const user = await prisma.user.upsert({
      where:  { palmynId: human_id },
      create: { palmynId: human_id, email },
      update: {},
    });

    // Set session
    req.session.userId = user.id;
    res.json({ redirect: '/dashboard' });

  } catch (err) {
    res.status(403).json({ error: err.message });
  }
});
# views/auth.py
import os, requests
from flask import request, session, redirect, abort

@app.route("/auth/palmyn/callback", methods=["POST"])
def palmyn_callback():
    token = (request.json or {}).get("token", "")

    r = requests.get(
        "https://palmyn.com/api/captcha.php",
        params={
            "q":      "siteverify",
            "secret": os.environ["PALMYN_SECRET_KEY"],
            "token":  token,
        },
        timeout=5,
    )
    data = r.json()

    if not data.get("success"):
        abort(403, "Verification failed")

    human_id       = data["human_id"]         # "hid_..."
    email          = data["email"]             # "user@example.com"
    verified_level = int(data["verified_level"])  # 0 | 1 | 2

    if verified_level < 2:
        abort(403, "Biometric verification required")

    # Upsert user in your DB
    user = User.query.filter_by(palmyn_id=human_id).first()
    if not user:
        user = User(palmyn_id=human_id, email=email)
        db.session.add(user)
        db.session.commit()

    session["user_id"] = user.id
    return {"redirect": "/dashboard"}

Verification Levels

Each user has a verified_level that reflects how far they've completed the Palmyn identity process.

0
UNVERIFIED
Account created but email not confirmed. Not recommended for any sensitive action.
1
EMAIL VERIFIED
Email address confirmed via OTP. Suitable for basic access and newsletter signups.
2
BIOMETRÍA ACTIVA
Hand registered + liveness-verified. Highest assurance level. Recommended for logins and payments.
💡

Most sites should require verified_level >= 2 for Login with Palmyn. Level 1 is useful for gating low-risk features while the user sets up biometrics.

Mobile Deep Link

When the Palmyn widget detects a mobile browser, it automatically replaces the QR code with an "Open Palmyn App" button that deep links directly to the challenge.

Mobile deep link flow diagram

If your site is registered with a redirect_uri, the Palmyn app will automatically redirect to it after the scan:

https://miapp.com/auth/palmyn/callback?palmyn_session=TOKEN_HERE
⚠️

The palmyn:// deep link only works if the user has the Palmyn app installed. Make sure to handle the case where the link doesn't open — display a fallback QR for desktop or instructions to install the app.

API Reference — captcha.php

Base URL: https://palmyn.com/api/captcha.php

POST verify public

Called by the widget when a user interacts with the button. Returns an action decision.

FieldTypeDescription
qstringMust be "verify"
sitekeystringYour public pk_live_... key
behaviorarrayEvent array collected by the widget
deviceobjectDevice fingerprint collected by the widget
urlstringCurrent page URL
require_freshbooleanDefault true. Set false to allow silent auth.
palmyn_sessionstring?Existing session token for silent auth

POST challenge_status public

Polled by the widget to check if the user completed the QR challenge.

FieldTypeDescription
qstringMust be "challenge_status"
challenge_idstringChallenge ID returned by verify

GET siteverify server-to-server

Validates a token returned by the widget. Call from your backend only.

ParamTypeDescription
qstringMust be "siteverify"
secretstringYour private sk_live_... key
tokenstringToken received from widget onSuccess

POST challenge_complete mobile app

Called by the Palmyn app after a successful palm scan. Not for third-party use.

POST get_site_info public

Returns the domain and tier for a site_key. Used by the app to display the site name before scanning.

FieldTypeDescription
qstringMust be "get_site_info"
site_keystringThe site's public key

POST register_site admin

Registers a new domain. Requires the admin secret.

FieldTypeDescription
qstringMust be "register_site"
domainstringDomain to authorize, e.g. miapp.com
tierstring"basic" or "verified"
admin_secretstringYour PALMID_ADMIN_SECRET from .env
redirect_uristring?Callback URL for mobile deep link flow
allow_silent_authint?0 or 1 — enable silent auth

API Reference — identity.php

Base URL: https://palmyn.com/api/identity.php
These endpoints are for users of the Palmyn mobile app (not for third-party site integrators).

POST check_auth_session public

Validates a Palmyn session token. Useful for backends that want to check session validity directly.

FieldTypeDescription
qstringMust be "check_auth_session"
session_tokenstringToken from the Palmyn app after a hand scan
Response
{
  "code": 200,
  "result": {
    "valid":          true,
    "human_id":       "hid_a1b2c3...",
    "email":          "user@example.com",
    "verified_level": 2,
    "expires_at":     1748397000
  }
}

Error Codes

Code / error-codeMeaning
invalid-input-secretThe secret_key passed to siteverify is wrong or doesn't exist
invalid-input-responseThe token is malformed, expired, or already used (tokens are single-use)
invalid_site_keyThe site_key in the widget doesn't match any registered site
origin_not_allowedThe request Origin doesn't match the domain registered for this site_key
challenge_not_foundThe challenge_id doesn't exist or expired
challenge_expiredThe 3-minute challenge window passed without completion
unauthorizedAdmin endpoint called without a valid admin_secret

HTTP status codes

StatusMeaning
200Success — check the success / code field in the response body
400Bad request — missing or invalid parameters
401Unauthorized — invalid JWT or admin secret
405Method not allowed — use POST (or GET for siteverify)