An Invoice is a request for a payment. When created with the Button API or the JavaScript API, 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, the callback
webhook will be called, and the Button/JavaScript onPaymentComplete
callback is called.
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 amount of the invoice, in currency . Must be positive and
rounded to the appropriate number of decimal places for the currency. |
currency
|
The currency being requested. For valid values, see Output Currencies. |
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 Input Currencies. 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.
Limited to 128kb of JSON data. Optional (default: {} ) |
allowPartial
|
If Optional (default: |
The Input Currency for an Invoice is the currency sent by the user.
Currently valid input currencies:
Currency | Description |
---|---|
BTC |
Bitcoin |
ETH |
Ethereum |
LTC |
Litecoin |
USDC |
USD Coin |
USDT |
Tether |
BUSD |
Binance USD |
GUSD |
Gemini Dollar |
PAX |
Paxos Standard |
USDS |
Stably Dollar |
AAVE |
Aave |
COMP |
Compound |
LINK |
Chainlink |
WBTC |
Wrapped Bitcoin |
BAT |
Basic Attention Token |
CRV |
Curve |
MKR |
Maker |
SNX |
Synthetix |
UMA |
UMA |
UNI |
Uniswap |
YFI |
yearn.finance |
The Output Currency for an Invoice is the currency which will be displayed to the user. For example, a US$10 invoice will have "amount": "10.00"
and "currency": "USD"
, and when paying, the user will be given the option to pay with (for example) 0.074 ETH or 0.0026 BTC.
Currently valid output currencies:
Currency | Description |
---|---|
USD |
US Dollars |
BTC |
Bitcoin |
ETH |
Ethereum |
A SpankPay button is the simplest way to accept SpankPay on your site.
When the button is clicked, the user will be presented with the SpankPay payment frame, and the data-on-payment
callback will be called once the payment is complete and your callback has accepted the payment.
<script src="https://unpkg.com/@spankchain-dev/spankpay-sdk"></script>
<script>
function onSpankPayPayment(payment) {
console.log(`Payment ${payment.status}`, payment)
}
</script>
<button
data-spankpay-key="test_quickstart_key"
data-amount="69.69"
data-currency="USD"
data-accepted-currencies="ETH,LTC,BTC"
data-metadata="{"orderId": "sc696969"}"
data-callback-url="https://pay-api.spankchain.com/quickstart/callback"
data-on-payment="onSpankPayPayment">
Pay with SpankPay!
</button>
See also:
For complete control over the user's SpankPay experience, the API can be called directly.
The spankpay.show(...)
method can be used to show the SpankPay payment frame.
const { spankpay } = require('spankpay')
const frame = spankpay.show({
apiKey: 'test_quickstart_key',
amount: '69.69',
currency: 'USD',
metadata: {
orderId: 'sc696969',
},
callbackUrl: 'https://pay-api.spankchain.com/quickstart/callback',
})
See also:
SpankPay supports fiat payments through Wyre. SpankPay supports debit card fiat payments through Wyre. Wyre will charge the user's debit card for the amount of the invoice, plus their debit fee, then send the seller's account the invoiced amount in Ethereum.
In order to enable debit (fiat) payments, your site must meet our criteria for whitelisting. If you believe your site matches our guidelines and wish to enable the feature, please reach out to [email protected] with the email address registered to your SpankPay account, the domain you wish to whitelist, and your geographic location. Once you have been whitelisted, the only thing left to do is enable fiat payments by passing the fiatEnabled: true
option in your JS method call:
spankpay.show({
apiKey: 'test_quickstart_key',
fiatEnabled: true,
amount: '69.69',
currency: 'USD',
metadata: {
orderId: 'sc696969',
},
callbackUrl: 'https://pay-api.spankchain.com/quickstart/callback',
})
The payment
event will be triggered when a payment has been received and the callback
url has accepted or rejected the payment.
The payment
argument will be a Payment object, and the status
should be checked to ensure the payment has succeeded. Note, however, that the payment will only fail if the callback
rejects the payment (see: Webhook Expected Response).
For example:
function onPayment(payment) {
console.log(`Payment ${payment.status}:`, payment)
if (payment.status == "succeeded") {
window.location.href = '/order-complete'
} else {
window.location.href = '/order-failed'
}
}
A payment is created when SpankPay receives a user's payment in response to an Invoice.
We _strongly_ recommend validating webhook signatures, otherwise it could be possible for an attacker to create fake payment confirmations.Attribute | Description |
---|---|
createdOn
|
The timestamp when the payment was first received. ISO 8601 format. |
status
|
One of
|
invoiceId
|
The ID of the corresponding Invoice. |
invoice
|
The corresponding Invoice object (see above). |
currency
|
The invoice's currency. |
amount
|
The invoiced amount. |
exchangeRate
|
The exchange rate used for this payment.
|
inputCurrency
|
The input currency selected by the user (ex, "ETH"). |
inputAmount
|
The amount of the input currency that was paid, in inputCurrency (ex,
"0.6969"). |
outputCurrency
|
The currency which will be credited to the merchant's account (currently
the inputCurrency , but in the future this will be configurable) |
outputAmount
|
The amount that will be credited to the merchant's account, in
|
outputAmountTarget
|
The amount (in currency ) that will be credit to the merchant's
account. |
feeAmountTarget
|
The fee taken by SpankPay, converted to
|
feeAmountInput
|
The fee taken by SpankPay, converted to inputCurrency . |
feeAmountOutput
|
The fee taken by SpankPay, converted to outputCurrency . |
receipt
|
The result of the webhook callback (or null when
the webhook is being called). |
receipt.type
|
Always "webhook"
|
receipt.url
|
The URL which was called |
receipt.status
|
One of "pending" , "failed" ,
or "succeeded" . |
receipt.calledOn
|
The timestamp of the last call (ISO 8601 format). |
receipt.responseStatus
|
The HTTP status code of the last request.
|
receipt.responseHeaders
|
The HTTP headers returned by the last request. |
receipt.responseBody
|
The content of the HTTP response.
|
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 callback
URL 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 callback
URL might be https://your-site.com/api/spankpay/callback?order-id=sc696969
.
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.
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 according to the following rules:
- 10 times, each 30 seconds apart (ie, every 30 seconds for 5 minutes)
- 10 times, each 5 minutes apart (ie, every 5 minutes for 50 minutes)
The Webhook Test Page can be used to send simulated webhooks.
At the moment, the test webhooks will send the body of a started invoice
Additionally, we recommend that developers use ngrok to create a public URL which can route to their local development server. During development, your application can be configured to automatically query ngrok for the developer's current public URL:
async function getPublicUrl() {
if (config.PUBLIC_URL)
return config.PUBLIC_URL
if (!config.ENVIRONMENT != 'development')
throw new Error('config.PUBLIC_URL has not been defined!')
try {
const res = await fetch('http://localhost:4040/api/tunnels')
} catch (e) {
throw new Error(
'Error connecting to ngrok to fetch public URL ' +
'(hint: did you run "ngrok"?). Original error: ' + e
)
}
const obj = await res.json()
for (const tun of obj.tunnels) {
return tun.public_url
}
throw new Error(
'Unexpected response from ngrok (tunnels found): ' +
JSON.stringify(obj)
)
}
!!! 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.
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))
}
}
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
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";
}
}
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 })
})