Developers

Reference snippets and integration helpers.

Three pieces engineering teams want before they start integrating: webhook signature verification, an API call example, and a sandbox seed command so the integration has data to chew on. Snippets in Node.js, Python, and Ruby.

In plain English
What is this?
Drop-in code samples for the three things every grantor integration touches: verifying inbound webhook signatures, calling the attestation API, and seeding sandbox data. All snippets are runnable - copy, paste, swap the env vars, ship.
How does it affect me?
If you're the engineer integrating Attestyx, this page saves you the back-and-forth on the boring parts (HMAC verification, replay window, raw-body capture) so you can focus on the part that's specific to your foundation.
Does it help me?
Three idioms have shipped with bugs in past integrations: (1) parsing JSON before HMAC verification, (2) skipping the timestamp replay check, (3) using == instead of timing-safe compare. The samples below get all three right.
Webhook signature verification

HMAC-SHA256 with replay defense.

Every webhook from Attestyx carriesX-JIL-SignatureandX-JIL-Timestampheaders. ComputeHMAC-SHA256(secret, timestamp + "." + raw_body)and compare with timing-safe equality. Reject anything older than 300 seconds.

Node.js (Express)

// Node.js - verify a Attestyx webhook signature
import crypto from 'crypto';
import express from 'express';

const app = express();
const SIGNING_SECRET = process.env.GRANTSPROOF_WEBHOOK_SECRET; // from /portal/settings

// Capture the raw body BEFORE express.json() so the signature
// matches the exact bytes we signed.
app.post('/webhooks/attestyx',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.header('X-JIL-Signature');
    const timestamp = req.header('X-JIL-Timestamp');
    if (!signature || !timestamp) return res.status(401).send('missing headers');

    // Reject events older than 5 minutes (replay defense)
    const age = Math.abs(Date.now() / 1000 - Number(timestamp));
    if (age > 300) return res.status(401).send('stale timestamp');

    const expected = crypto
      .createHmac('sha256', SIGNING_SECRET)
      .update(timestamp + '.' + req.body.toString('utf8'))
      .digest('hex');

    if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
      return res.status(401).send('signature mismatch');
    }

    const event = JSON.parse(req.body.toString('utf8'));
    // process event...
    res.status(200).send('ok');
  });

Python (Flask)

# Python (Flask) - verify a Attestyx webhook signature
import hmac, hashlib, time, os
from flask import Flask, request, abort

app = Flask(__name__)
SIGNING_SECRET = os.environ['GRANTSPROOF_WEBHOOK_SECRET'].encode()

@app.post('/webhooks/attestyx')
def webhook():
    signature = request.headers.get('X-JIL-Signature')
    timestamp = request.headers.get('X-JIL-Timestamp')
    if not signature or not timestamp:
        abort(401)
    if abs(time.time() - int(timestamp)) > 300:
        abort(401)  # stale

    body = request.get_data()  # raw bytes BEFORE JSON parse
    payload = f'{timestamp}.'.encode() + body
    expected = hmac.new(SIGNING_SECRET, payload, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, signature):
        abort(401)

    event = request.get_json()
    # process event...
    return ('ok', 200)

Ruby (Rails)

# Ruby (Rails) - verify a Attestyx webhook signature
require 'openssl'

class AttestyxWebhooksController < ActionController::API
  SIGNING_SECRET = ENV.fetch('GRANTSPROOF_WEBHOOK_SECRET')

  def create
    signature = request.headers['X-JIL-Signature']
    timestamp = request.headers['X-JIL-Timestamp']
    head :unauthorized and return unless signature && timestamp
    head :unauthorized and return if (Time.now.to_i - timestamp.to_i).abs > 300

    body = request.raw_post # before JSON parse
    payload = "#{timestamp}.#{body}"
    expected = OpenSSL::HMAC.hexdigest('sha256', SIGNING_SECRET, payload)

    head :unauthorized and return unless ActiveSupport::SecurityUtils
      .secure_compare(expected, signature)

    event = JSON.parse(body)
    # process event...
    head :ok
  end
end
API call example

Issue an attestation.

Authentication is theX-API-Key header. Issue a sandbox key from /portal/api-keys; switch to a live key after the MSA + DPA are signed.

// Node.js - issue an attestation
const r = await fetch('https://attestyx.com/api/v1/attestations', {
  method: 'POST',
  headers: {
    'X-API-Key': process.env.GRANTSPROOF_API_KEY,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    recipient: {
      legal_name: 'Acme Charitable Trust',
      ein: '12-3456789',
      jurisdiction: 'us'
    },
    payment: {
      amount_cents: 50_000_00,
      currency: 'USD',
      program_code: 'education-rural-2026',
      purpose: 'Q2 milestone'
    }
  })
});
const att = await r.json();
console.log(att.decision, att.attestation_id);
Sandbox seed

Demo data, one curl.

POST /v1/sandbox/seed populates your tenant with 8 sample attestations across 5 jurisdictions (6 YES, 1 REVIEW, 1 NO with hard blocks) plus 6 corresponding disbursements. Idempotent - re-calling returns the existing seed_id rather than duplicating.

# Seed your sandbox tenant with 8 demo attestations + 6 disbursements
curl -X POST https://attestyx.com/api/v1/sandbox/seed \
  -H "X-API-Key: $GRANTSPROOF_API_KEY"

# Then exercise the integration flow against seeded data:
curl https://attestyx.com/api/v1/reports/quarterly \
  -H "X-API-Key: $GRANTSPROOF_API_KEY"

Stuck on something?

We sit on the call with your engineers if it helps. No NDA needed for technical discovery.

Schedule integration help →