Payment Webhook Subscriptions
This version of webhook subscription is deprecated and no longer getting any improvements to its features. Please use our improved webhooks here
The GraphQL standard has first class support for subscriptions. Like GraphQL queries or mutations, GraphQL subscriptions allow you to specify only the data you need. With subscriptions, this data is data pushed to you over time rather than being retrieved immediately.
The subscription specification itself is transport agnostic, meaning that subscriptions could be delivered by any mechanism, including websocket, webhook or email. Stitch has currently opted to support webhooks, but may choose to expand transports to include websockets in the future.
Creating a Subscription
Each product has a slightly different subscription query. The root of the subscription is either a client
or a user
, representing the two kinds of tokens on the Stitch platform. However, only client
level subscriptions are supported for now.
The subscription query has 3 parameters explained in the below table:
Parameter | Required or Optional | Usage |
---|---|---|
url | Required | The endpoint Stitch will submit the subscription data via a POST request. Due to the sensitive nature of the data being transmitted, the url must use the HTTPS protocol |
headers | Optional | Allows specifying a set of headers which will be included with the POST request sent to the client's webhook URL |
secret | Required for Signed Webhooks | A secure passphrase used to sign the request data sent to the client's webhook URL. The same passphrase will be used by the client to decrypt the data and verify authenticity |
Only a single subscription is required per webhook URL. A single webhook subscription will allow you to receive all payment notification events. Duplicate subscriptions are recognized and ignored by our internal systems. Multiple subscriptions are only necessary when changing the webhook service url or when periodic subscriptions (eg. once a week) are done to ensure subscription health.
Webhook Subscription Examples
Pay By Bank Unsigned
If the subscription is successfully created, the body returned by the request will look like the example shown in the
Example Response
tab in the widget above, with data
being null, and the subscriptionId
being contained with the extensions
field.
Pay By Bank Signed
If the subscription is successfully created, the body returned by the request will look like the example shown in the
Example Response
tab in the widget above, with data
being null, and the subscriptionId
being contained with the extensions
field.
Refunds Unsigned
If the subscription is successfully created, the body returned by the request will look like the example shown in the
Example Response
tab in the widget above, with data
being null, and the subscriptionId
being contained with the extensions
field.
Refunds Signed
If the subscription is successfully created, the body returned by the request will look like the example shown in the
Example Response
tab in the widget above, with data
being null, and the subscriptionId
being contained with the extensions
field.
Disbursements Unsigned
If the subscription is successfully created, the body returned by the request will look like the example shown in the
Example Response
tab in the widget above, with data
being null, and the subscriptionId
being contained with the extensions
field.
Disbursements Signed
If the subscription is successfully created, the body returned by the request will look like the example shown in the
Example Response
tab in the widget above, with data
being null, and the subscriptionId
being contained with the extensions
field.
Settlements Signed
If the subscription is successfully created, the body returned by the request will look like the example shown in the
Example Response
tab in the widget above, with data
being null, and the subscriptionId
being contained with the extensions
field.
Settlements Unsigned
If the subscription is successfully created, the body returned by the request will look like the example shown in the
Example Response
tab in the widget above, with data
being null, and the subscriptionId
being contained with the extensions
field.
DirectDeposits Unsigned
If the subscription is successfully created, the body returned by the request will look like the example shown in the
Example Response
tab in the widget above, with data
being null, and the subscriptionId
being contained with the extensions
field.
DirectDeposits Signed
If the subscription is successfully created, the body returned by the request will look like the example shown in the
Example Response
tab in the widget above, with data
being null, and the subscriptionId
being contained with the extensions
field.
Receiving a Webhook Subscription Event
Whenever a new event for a subscription is available, it will be posted to the specified target URL
,
along with the specified headers
. For a signed Pay By Bank subscription,
the body should look something like the example below.
The shape of the data in the below example is modelled after the signed Pay By Bank subscription query above.
{
"data": {
"client": {
"paymentInitiationRequests": {
"node": {
"id": "cGF5cmVxL2Y1YzY5ODRmLWI4MWMtNDg5MS05MjFkLTVkYzJmZjI3MzRkYg==",
"externalReference": "de6b4d3c-af5f-488d-89b9-6f855973bf11",
"state": {
"__typename": "PaymentInitiationRequestCompleted",
"date": "2021-04-09T10:01:25.692Z"
}
},
"subscriptionId": "c3ViL2YxYzVjMTZkLWE0NjQtNDgxYS05NTUyLWUyMjhiYjQzNGE0NAo=",
"eventId": "cGF5cmVxLzdmZmIwNGFkLTExMDQtNDcwNy04NjU5LTI1ZWEzNTZhYjU3Yg==",
"time": "2021-04-09T09:41:36.475Z"
}
}
}
}
Listing Webhook Subscriptions
To list all the webhook subscriptions your client has active across all the products, you can run the below query. Note that only the products for which you have an active webhook subscription will be in the response.
Unsubscribing from a Subscription
To unsubscribe from a webhook subscription, you can respond to the webhook payload with the HTTP status code 410
,
symbolising that the endpoint is no longer available.
Alternatively, you can also submit a mutation to the API with the subscriptionId
passed in as an argument. Incase you
can't recall the correct subscriptionId
, you can use the subscription listing query to
identify the subscription you wish to unsubscribe from.
An example is as shown below:
Signed Webhooks
If a webhook secret is specified, Stitch signs the webhook body by adding a X-Stitch-Signature
header to the request.
Currently, Stitch supports the HMAC-SHA256
algorithm for this purpose. To learn more about subscribing to signed webhooks
across our different products, please refer to the examples section.
Validating the Subscription Payload
To validate that a given request was genuine, you'll need to calculate the HMAC-SHA256
of the body bytes with the hash
being hex (base-16) encoded, and compare it to the value found in the X-Stitch-Signature
header. The header will contain
two parts, a signature and a timestamp (in seconds since the epoch), in the following format:
X-Stitch-Signature: t=1670320325,hmac_sha256=b3c937fdf0adfa8bc51f18049100c4ea5b32c0305dafbc7429d1da633e6a9648
To protect against cryptographic timing attacks, it is best practice to use a constant-time string comparison to compare the expected signature to each of the received signatures.
To prevent replay attacks, always include the eventId
field and time
fields in your subscriptions, as these will allow
you to disambiguate duplicate events, and also ensure that the signature changes for each event, even if the data is the same.
Quite often, clients' systems will only allow whitelisted IPs past their internal firewalls. In order to ensure you're able to receive webhooks from us, we ask that you contact support via our Support Form 💬
Sample Code
The following samples illustrate how to validate the payload for a few different languages. If your language of choice is not listed, the core logic in the samples will still apply.
The steps behind the verification process are as follows:
- Extract the timestamp and HMAC SHA256 hash from the
X-Stitch-Signature
header. - Concatenate the timestamp and the request body, separated by a period (e.g. t + '.' + requestBody).
- Compute the hmac_sha256 hash of the concatenated string in (2) above.
- Compare the hash with the provided signature, if possible, using a constant-time comparison function (e.g.
crypto.timingSafeEqual
in NodeJS). - Reject the request if the hash you computed does not match the provided signature.
- Your server responds with a 200 or any 2XX response code to indicate the webhook event was received. If a 2XX response is not received, the webhook event will be retried upto 10 times (the retry logic has exponential backoff between attempts).
Ensure that the concatenated string in step (2) above doesn't have any whitespaces, either between the key and value pairs or between the different values. If these extra spaces are there, then the calculated signature won't match the incoming signature. An easy way to ensure this is to deserialize then serialize the JSON string before concatenating.
- NodeJS
- .NET
- Python 3
- Java
- PHP
import crypto from "crypto";
const SECRET = "your hmacSha256Key";
const signatureHeader = req.get("X-Stitch-Signature");
if (!signatureHeader) {
throw new Error("Missing signature");
}
const signature: Record<string, string> = {};
for (const pair of signatureHeader.split(",")) {
const [k, v] = pair.split("=");
signature[k] = v;
}
const hash = crypto
.createHmac("sha256", SECRET)
.update(signature.t)
.update(".")
.update(body)
.digest("hex");
const areEqual = crypto.timingSafeEqual(
Buffer.from(hash, "hex"),
Buffer.from(signature.hmac_sha256, "hex")
);
if (!areEqual) {
throw new Error(
`Could not validate signature. Expected request signature ${signature.hmac_sha256} to match ${hash}`
);
}
console.log("Signature is valid!");
using System;
using System.Collections.Generic;
using System.Text;
using System.Security.Cryptography;
using System.Net;
using Newtonsoft.Json;
public class Program
{
static string GetHmacHash(string input, string secret)
{
if (string.IsNullOrWhiteSpace(input)) return "";
Encoding encoding = Encoding.UTF8;
byte[] keyByte = encoding.GetBytes(secret);
byte[] messageBytes = encoding.GetBytes(input);
using (HMACSHA256 hmacsha256 = new HMACSHA256(keyByte))
{
byte[] hashmessage = hmacsha256.ComputeHash(messageBytes);
return BitConverter.ToString(hashmessage).Replace("-", "").ToLower();
}
}
/// <param name="requestBody"> The raw request body submitted to your webhook endpoint </param>
/// <param name="signatureHeader"> The value of the X-Stitch-Signature header from the submitted webhook request </param>
public static void Main(string requestBody, string signatureHeader)
{
var signatureKey = Environment.GetEnvironmentVariable("SECRET_KEY");
var signature = new Dictionary<string, string>();
foreach (var pair in signatureHeader.Split(','))
{
var d = pair.Split('=');
signature[d[0]] = d[1];
}
var request = JsonConvert.DeserializeObject(requestBody);
string requestBodyString = JsonConvert.SerializeObject(request);
string hashComputeInput = signature["t"] + "." + requestBodyString;
string computedHash = GetHmacHash(hashComputeInput, signatureKey);
var incomingHash = signature.GetValueOrDefault("hmac_sha256");
if (computedHash == incomingHash)
{
Console.WriteLine("Computed hash and incoming hash match");
}
else
{
throw new WebException($"Could not match computed {computedHash} with incoming {incomingHash}");
}
}
}
import hashlib
import hmac
import json
import os
def get_signature_sections(signature):
"""
Gets the individual sections of X-Stitch-Signature as a dictionary object
"""
signature_parts = signature.split(',')
parsed_signature = {}
for signature_part in signature_parts:
sections = signature_part.split('=')
parsed_signature[sections[0]] = sections[1]
return parsed_signature
def calculate_hmac_signature(to_sign):
"""
Calculate the HMAC SHA256 hash from the input string
Will read the secret key from environment variables
"""
secret = os.environ['WEBHOOK_SECRET_KEY'].encode('utf-8')
signature = hmac.new(secret, to_sign.encode('utf-8'), hashlib.sha256)
return signature.hexdigest()
def compare_signatures(calculated_signature, incoming_signature):
"""
Compares the computed HMAC SHA256 hash with the one in X-Stitch-Signature
Tries to first use compare_digest() to reduce vulnerability to timing attacks
Falls back to regular string comparison if it's not available
https://docs.python.org/3/library/hmac.html#hmac.HMAC.hexdigest
"""
try:
return hmac.compare_digest(calculated_signature, incoming_signature)
except AttributeError:
return calculated_signature == incoming_signature
stitch_signature_header = 'value of request.headers['X-Stitch-Signature']'
request_body = 'value of the raw POST body sent to your webhook endpoint'
payload = json.loads(request_body)
# parse to string without any spaces between key pairs
parsed_payload = json.dumps(payload, separators=(',', ':'))
parsed_signature = get_signature_sections(stitch_signature_header)
hash_input = f'{parsed_signature["t"]}.{parsed_payload}'
computed_hash = calculate_hmac_signature(hash_input)
compare_hashes = compare_signatures(computed_hash, parsed_signature["hmac_sha256"])
if compare_hashes:
print("Computed hash and incoming hash match")
else:
print(f'Computed hash {computed_hash} and incoming hash {parsed_signature["hmac_sha256"]} don\'t match')
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.UnsupportedEncodingException;
import java.util.HashMap;
import java.util.Map;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
public class GenerateSignature {
private static final String HMAC_SHA256 = "HmacSHA256";
public static void main(String[] args) throws UnsupportedEncodingException, JsonProcessingException {
// this will come as part of the request headers as X-Stitch-Signature
String stitchSignatureHeader = "'value of request.headers['X-Stitch-Signature']'";
// this will come as the request body
String requestPayload = "value of the raw POST body sent to your webhook endpoint";
// should match the same secure string used making the webhook subscription call
byte[] secretKey = System.getenv("WEBHOOK_SECRET_KEY").getBytes();
Map<String, String> parsedSignatureHeader = getSignatureSections(stitchSignatureHeader);
// we need a deserialized version of the string to unify representation when generating valueToDigest
// this ensures the string we use to generate the hash matches what Stitch used to generate the same hash
ObjectMapper objectMapper = new ObjectMapper();
Map<String, Object> requestPayloadJson = objectMapper.readValue(requestPayload, new TypeReference<HashMap>(){});
String requestPayloadString = objectMapper.writeValueAsString(requestPayloadJson);
// the signature was generated by Stitch using the format <timestamp>.<deserialized-string>, so we mirror that
String valueToDigest = String.format("%s.%s", parsedSignatureHeader.get("t"), requestPayloadString);
String incomingHash = parsedSignatureHeader.get("hmac_sha256");
String computedHash = generateSignature(secretKey, valueToDigest);
if (computedHash.equals(incomingHash))
{
System.out.println("Computed hash and incoming hash match");
} else {
System.out.printf("Could not match computed hash %s with incoming hash %s", computedHash, incomingHash);
}
}
public static Map<String, String> getSignatureSections(String signatureHeader) {
Map<String, String> signatureHeaderMap = new HashMap<>();
String[] signatureHeaderPairs = signatureHeader.split(",");
for (String signatureHeaderPair : signatureHeaderPairs) {
String[] signatureValue = signatureHeaderPair.split("=");
signatureHeaderMap.put(signatureValue[0], signatureValue[1]);
}
return signatureHeaderMap;
}
public static String bytesToHex(byte[] bytes) {
StringBuilder result = new StringBuilder();
for (byte aByte : bytes) {
result.append(String.format("%02x", aByte));
}
return result.toString();
}
public static String generateSignature(byte[] secretKey, String toSign) throws UnsupportedEncodingException {
try {
Mac mac = Mac.getInstance(HMAC_SHA256);
SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey, HMAC_SHA256);
mac.init(secretKeySpec);
byte[] macData = mac.doFinal(toSign.getBytes());
return bytesToHex(macData);
} catch (Exception e) {
throw new RuntimeException("Failed to calculate hmac-sha256", e);
}
}
}
function get_signature_sections($signature)
{
$signature_parts = explode(",", $signature);
$parsed_signature = array();
for ($i = 0; $i < count($signature_parts); $i++) {
$temp = explode("=", $signature_parts[$i]);
$parsed_signature[$temp[0]] = $temp[1];
}
return $parsed_signature;
}
$stitch_signature_header = 'value of request.headers['X-Stitch-Signature']';
$request_body = 'value of the raw POST body sent to your webhook endpoint';
$webhook_secret = getenv('WEBHOOK_SECRET_KEY');
$payload = stripslashes(json_encode($request_body));
$parsed_payload = json_decode($payload);
$parsed_signature = get_signature_sections($stitch_signature_header);
$hash_input = sprintf("%s.%s", $parsed_signature["t"], $parsed_payload);
$computed_hash = hash_hmac("sha256", $hash_input, $webhook_secret);
if($computed_hash === $parsed_signature["hmac_sha256"]) {
echo "Computed hash and incoming hash match";
} else {
echo sprint("Computed hash %s and incoming hash %s don't match", $computed_hash, $parsed_signature["hmac_sha256"]);
}