Skip to content

SpankPay v2 API Reference

Invoice

An Invoice is a request for a payment. When created with a pay Button or by calling spankpay.show() directly, the user will be presented with a payment form prompting them to send cryptocurrency equivalent in value to the requested amount. Once the cryptocurrency has been received, a webhook will be sent using the callbackUrl supplied.

Attribute Description
id The invoice's ID (assigned by SpankPay).
createdOn The timestamp the invoice was created (ISO 8601 format, assigned by SpankPay).
status The invoice's status. One of pending (awaiting payment), pending-callback (payment received, waiting for webhook to complete), failed, or succeeded.
apiKey The API key used to create this invoice.
amount The original amount stated on the invoice, in currency - not necessarily the amount received. Must be positive and rounded to the appropriate number of decimal places for the currency.
currency The currency being requested. For valid values, see Currency.
description A description of the goods / services within the invoice.

*Optional (but recommended).

acceptedCurrencies

A list of currencies which should be accepted for this invoice. For valid values, see Accepted Currencies - please note that currencies such as LTC were previously supported in V1 but are no longer supported in V2.

*Optional (default: all available currencies)

callbackUrl The callback URL used to notify your application when the payment succeeds. See also: Webhook Callbacks.
metadata Arbitrary metadata provided by the caller, stored and returned along with the invoice. We suggest including the order or invoice number, and an opaque customer ID.

*Required (use {} to not include any metadata)
allowPartial

If allowPartial is true, the webhook callback will be called unconditionally when the first payment is received, whether the payment is less than, equal to, or greater than the invoice amount.

*Required (must be set to true)

Accepted Currencies

The Input Currency for an Invoice is the currency sent by the user.

NOTE: Debit and balance payments are enabled by default for all invoices. Restrict the allowed cryptocurrencies for invoices by using the data-accepted-currencies button attribute with a subset of the list of currencies below.

Currently supported input currencies:

Currency Description
BTC Bitcoin
ETH Ethereum
USDC USD Coin
DAI DAI

Currency

The Currency field for an Invoice is the currency that the invoice amount will be priced in. For example, if data-amount="10" and data-currency="EUR", the user would be presented with an invoice for 10 euros. Similarly if data-currency="USD" the user would be presented with an invoice for $10 USD.

NOTE: your holding currency relates to the invoice's currency - if, for example, your holding currency is set to ETH but you are paid in BTC, you will incur a swap fee for that transaction.

Currently valid currencies:

Currency Description
USD US Dollars
GBP Great British Pound
EUR Euro

Webhook Callbacks

SpankPay will POST a message to your application server when it receives a payment, and the payment will be considered successful once it receives a response containing {"received": true}.

The callbackUrl is provided when the Invoice is created, and we recommend including some metadata in the URL which your application can use to credit the appropriate order.

For example, if you assign each order an ID, the callbackUrl might be https://your-site.com/api/spankpay/callback?order-id=sc696969.

Webhook Format

Webhook messages will take the following format:

POST /quickstart/callback
Content-Type: text/plain
X-SpankPay-Key: test_quickstart_key
X-SpankPay-Signature: t=1551389518&s=b613679a0814d9ec…

{
    "type": "payment",
    "payment_id": "pay_c493715653c",
    "createdOn": "1969-06-09T06:09:06.969Z",
    "invoiceId": "inv_f95d778c35f",
    "invoice": { ... },
    "amount": "69.69",
    "amountCurrency": "USD",
    "inputAmount": "0.6969",
    "inputCurrency": "ETH",
    "inputTx": "0x2144292c5ad…",
    ...
}

Note: The Content-Type will be text/plain instead of application/json as might be expected. This is to ensure that web frameworks like Express do not attempt to parse the request body as JSON, and instead make the raw string available to the request handler so it can more easy check the signature.

Note: The amount field on the webhook payload represents the total output amount in USD that the user should be credited, less any fees from premiumAmount.

Expected Response

The webhook endpoint must return an HTTP 200 response with a JSON object containing { "received": true }. Other metadata may optionally be included in the JSON object, and it will be returned verbatim in the Payment's receipt.response field.

If the webhook endpoint returns a non-200 response, or a body that does not contain { "received": true }, the webhook will be retried.

Security

Tip

We strongly recommend validating webhook signatures, otherwise it could be possible for an attacker to create fake payment confirmations.

To verify that webhooks are authentically from SpankPay, the content can be verified using the X-SpankPay-Signature header.

Additionally, you include query parameters in the callback URL (for example, …/quickstart/callback?customer-id=69420), there is a very small risk that a man-in-the-middle attack between SpankPay's servers and your servers could alter these query parameters. For complete correctness, we recommend verifying the requested URL against the receipt.url field, which will be URL originally requested by SpankPay.

Validating Webhook Signatures

javascript tab="Javascript (manually)"
const crypto = require('crypto')

const crypto = require('crypto')

/**
 * Decodes a SpankPay webhook, returning a triple of:
 *   [data, timestamp, error]
 *
 * Where `data` is the webhook object, and `timestamp` is the
 * call's timestamp (integer seconds since epoch, UTC).
 *
 * If an error is encountered (for example, because the
 * signature is invalid), `error` will be a non-null
 * string describing the error.
 *
 * For example:
 *   const [data, timestamp, error] = decodeSpankPayWebhook(
 *     process.env.SPANKPAY_API_SECRET,
 *     req.headers['x-spankpay-signature'],
 *     req.body,
 *   )
 */
function decodeSpankPayWebhook(secret, sig, data) {
    if (!data || data.slice(0, 1) != '{') {
        const msg = (
            `Empty or non-JSON webhook data: ` +
            JSON.stringify(shorten(data))
        )
        return [null, null, msg]
    }

    const sigData = {}
    sig.split('&').forEach(bit => {
        const [key, val] = bit.split('=')
        sigData[key] = val
    })

    const timestamp = parseInt(sigData.t)
    if (!isFinite(timestamp))
        return [null, null, `Invalid or missing timestamp: ${sig}`]

    const hash = crypto.createHmac('sha256', secret)
    hash.update(`${timestamp}.${data}`)
    const actualSig = hash.digest('hex')
    if (sigData.s !== actualSig)
        return [null, null, `Invalid signature. ${sigData.s} !== ${actualSig}`]

    let dataObj
    try {
        dataObj = JSON.parse(data)
    } catch (e) {
        return [null, null, `Error decoding JSON: ${'' + e}`]
    }

    return [dataObj, timestamp, null]
}

function shorten(s, len) {
    if (!len)
        len = 16

    if (!s || s.length <= len)
        return s

    return s.slice(0, len / 2) + '…' + s.slice(s.length - len / 2)
}

function signSpankPayData(secret, data, t) {
    if (t === undefined)
      t = parseInt(Date.now() / 1000)
    const hash = crypto.createHmac('sha256', secret)
    hash.update(`${t}.${data}`)
    return `t=${t}&s=${hash.digest('hex')}`
}

if (typeof require !== 'undefined' && require.main == module) {
  const secret = 'sk_spankpay'
  const data = '{"SpankPay": "BOOTY"}'
  const sig = signSpankPayData(secret, data, 696969)
  console.log(`Signing '${data}' with secret key '${secret}': ${sig}`)

  examples = [
    ["correctly signed", sig, data],
    ["missing timestamp", "", data],
    ["missing signature", "t=696969", data],
    ["invalid signature", "t=696969&s=invalid", data],
    ["invalid data", sig, '{"invalid": true}'],
    ["empty data", sig, null],
    ["non-JSON data", sig, "invalid"],
  ]
  for (const [name, sig, data] of examples) {
    console.log(`Decoding ${name}:`, decodeSpankPayWebhook(secret, sig, data))
  }
}
python tab="Python (manually)"
from __future__ import print_function

import sys
PY3 = sys.version_info[0] == 3

import hmac
import time
import json
import hashlib

if PY3:
    from urllib.parse import parse_qsl
else:
    from urlparse import parse_qsl


def decode_spankpay_webhook(secret, sig, data):
    """ Decodes a SpankPay webhook, returning a triple of: `(data, timestamp,
        error)`

        Where `data` is the webhook object, and `timestamp` is the call's
        timestamp (integer seconds since epoch, UTC).

        If an error is encountered (for example, because the signature is
        invalid), `error` will be a non-null string describing the error.

        For example::

            (data, timestamp, error) = decode_spankpay_webhook(
                app.config.SPANKPAY_API_SECRET,
                request.headers['x-spankpay-signature'],
                request.data,
            )
    """

    secret = to_bytes(secret)
    data = data and to_bytes(data)
    if not data or data[:1] != b"{":
        return (None, None, "Empty or non-JSON webhook data: %r" %(shorten(data), ))

    sig_data = dict(parse_qsl(sig))

    try:
        timestamp = int(sig_data.get("t"))
    except (ValueError, TypeError):
        return (None, None, "Invalid or missing timestamp: %r" %(sig, ))

    to_sign = b"%d.%s" %(timestamp, data)
    actual_sig = hmac.new(secret, to_sign, hashlib.sha256).hexdigest()
    if sig_data.get("s") != actual_sig:
        return (None, None, "Invalid signature. %r != %r" %(sig_data.get("s"), actual_sig))

    try:
        data_obj = json.loads(data)
    except ValueError as e:
        return (None, None, "Error decoding JSON: %s" %(e, ))

    return (data_obj, timestamp, None)

def to_bytes(s):
    # Note: use "ascii" instead of "utf-8" here because, in this context, we
    # should only ever get ASCII input (ie, because JSON is ASCII, not unicode)
    # and we should fail early if unicode sneaks in.
    return (
        s if isinstance(s, bytes) else
        bytes(s, "ascii") if PY3 else
        str(s)
    )

def shorten(s, n=16):
    if not s or len(s) < n:
        return s
    return s[:n//2] + b"..." + s[-n//2:]

def sign_spankpay_data(secret, data, timestamp=None):
    secret = to_bytes(secret)
    data = to_bytes(data)
    timestamp = int(timestamp if timestamp is not None else time.time())
    data = b"%d.%s" %(timestamp, data)
    sig = hmac.new(secret, data, hashlib.sha256).hexdigest()
    return "t=%s&s=%s" %(timestamp, sig)

if __name__ == '__main__':
    secret = 'sk_spankpay'
    data = '{"SpankPay": "BOOTY"}'
    sig = sign_spankpay_data(secret, data, 696969)
    print("Signing %r with secret %r: %s" %(data, secret, sig))
    examples = [
        ("correctly signed", sig, data),
        ("missing timestamp", "", data),
        ("missing signature", "t=696969", data),
        ("invalid signature", "t=696969&s=invalid", data),
        ("invalid data", sig, '{"invalid": true}'),
        ("empty data", sig, None),
        ("non-JSON data", sig, "invalid"),
    ]
    for (name, s, d) in examples:
        print("Decoding %s: %s" %(name, decode_spankpay_webhook(secret, s, d), ))
php tab="PHP (manually)"
<?php

declare(strict_types=1);

/**
 * Decodes a SpankPay webhook, returning: `[$data, $timestamp,
 * $error]`
 *
 * Where `$data` is the webhook object (as an associative array), and
 * `$timestamp` is the call's timestamp (integer seconds since epoch, UTC).
 *
 * If an error is encountered (for example, because the signature is
 * invalid), `$error` will be a non-null string describing the error.
 *
 * For example:
 *
 *     define('SPANKPAY_API_SECRET', "sk_...");
 *
 *     list($data, $timestamp, $error) = spankpay_decode_webhook(
 *         SPANKPAY_API_SECRET,
 *         $_SERVER['HTTP_X_SPANKPAY_SIGNATURE'],
 *         file_get_contents("php://input")
 *     );
 */
function spankpay_decode_webhook(string $secret, string $sig, string $data) {
    $repr = function ($val) {
        return var_export($val, true);
    };

    if (!$data || substr($data, 0, 1) !== '{') {
        $msg = "Empty or non-JSON webhook data: {$repr($data ?? spankpay_shorten($data))}";
        return [null, null, $msg];
    }

    parse_str($sig, $sig_data);

    $timestamp = $sig_data['t'] ?? null;
    if (!is_numeric($timestamp)) {
        $msg = "Invalid or missing timestamp: {$repr($timestamp)}";
        return [null, null, $msg];
    }

    $expected_sig = $sig_data['s'] ?? null;
    $to_sign = "$timestamp.$data";
    $actual_sig = hash_hmac('sha256', $to_sign, $secret);
    if (!hash_equals($expected_sig ?? "", $actual_sig)) {
        spankpay_debug("Secret key: {$repr(spankpay_shorten($secret))}; data: {$repr($to_sign)}");
        $msg = "Invalid signature. {$repr($expected_sig)} !== {$repr($actual_sig)}";
        return [null, null, $msg];
    }

    $data_arr = json_decode($data, true);
    if (!$data_arr) {
        return [null, null, "Error decoding JSON: {$repr($data)}"];
    }

    return [$data_arr, (int) $timestamp, null];
}

function spankpay_debug(string $msg) {
    if (defined('SPANKPAY_DEBUG') && SPANKPAY_DEBUG) {
        echo "SPANKPAY_DEBUG: $msg\n";
    }
}

function spankpay_shorten(string $str, integer $len = null) {
    $len = $len ?? 16;

    if (strlen($str) <= $len) {
        return $str;
    }

    return substr($str, 0, $len / 2) . "..." . substr($str, -$len / 2);
}

function spankpay_sign_data($secret, $data, $t = null) {
    $t = $t ?? time();
    $s = hash_hmac('sha256', "$t.$data", $secret);
    return "t=$t&s=$s";
}

if (!count(debug_backtrace())) {
    define('SPANKPAY_DEBUG', true);
    $secret = 'sk_spankpay';
    $data = '{"SpankPay": "BOOTY"}';
    $sig = spankpay_sign_data($secret, $data, 696969);
    echo "Signing '$data' with secret key '$secret': $sig\n";
    $examples = [
        ["correctly signed", $sig, $data],
        ["missing timestamp", "", $data],
        ["missing signature", "t=696969", $data],
        ["invalid signature", "t=696969&s=invalid", $data],
        ["invalid data", $sig, '{"invalid": true}'],
        ["non-JSON data", $sig, 'invalid']
    ];
    foreach ($examples as $example) {
        $result = spankpay_decode_webhook($secret, $example[1], $example[2]);
        echo "Decoding ${example[0]}: " . var_export($result, true) . "\n";
    }
}

Preventing Replay Attacks

To ensure your application only processes each webhook once, we recommend using the signature as a nonce. For example:

app.post('/spankpay/callback', async (req, res) => {
    const sig = req.headers['x-spankpay-signature']
    // ... validate signature ...

    try {
        const firstUse = await redis.set(`spankpay-webhook:${sig}`, '1', {
            // The nx - Not Exists - flag ensures the key can only be set once
            nx: true,
            // The ex - EXpire - flag ensures the key will expire after an hour
            ex: 60 * 60,
        })
        if (!firstUse)
            return res.json({ received: true })

        // ... handle webhook ...
    } catch (e) {
        // If there is an error, clear the flag so that the webhook
        // will be processed on a subsequent request.
        // NOTE: your application must be careful not to leave the
        //       webhook in a partially processed state, otherwise
        //       there may be inconsistencies when it is retried.
        await redis.del(`spankpay-webhook:${sig}`)
        throw e
    }

    return res.json({ received: true })
})