Signing API requests is optional but recommended for security reasons.
To further secure your usage of the Numeral API, Numeral enables you to sign your API requests.
1. Initial setup
In this section, you are going to create a pair of cryptographic keys:
- A private key used to sign API requests
- A public key used by Numeral to verify API requests
The examples below use the openssl
program in a Unix system terminal.
1.1. Create the private key
openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:4096
This command generates and store the private key in a PEM file named private_key.pem
.
1.2. Export the corresponding public key
openssl rsa -in private_key.pem -pubout > public_key.pem
This command exports the corresponding public key in a PEM file named public_key.pem
.
1.3. Send your public key to Numeral
Send the public_key.pem
file corresponding to your public key to [email protected].
1.4. Numeral provides you with the public key identifier
After receiving and storing your public key, Numeral will provide you with the public key identifier. This identifier is required to sign your API requests. It looks like 2fae2e24-fc1a-40d3-bb2a-5dc3a1f5c726
.
Security recommendations
- Do not use the same key pairs on different environments. Use different keys for sandbox and production environments
- Make sure your private key is stored securely. Only you should have access to it.
- Consider rotating your key pairs regularly (e.g., once every year).
- In case your private key is compromised, notify us immediately at [email protected].
2. Signing API requests
2.1. Create the signature base
The signature base is the raw string containing all the components covered by the signature. Let’s start with two examples:
"@method": GET
"@authority": api.numeral.io
"@request-target": /v1/payment_orders
"@signature-params": ("@method" "@authority" "@request-target");alg="rsa-v1_5-sha256";keyid="your-public-key-identifier";created=1675688690
"@method": POST
"@authority": api.numeral.io
"@request-target": /v1/payment_orders
"content-digest": sha-256=:9AmU2hRsZnqEo2HiNTLacLN0fOs8YmDiuX4WYeYWvh0=:
"@signature-params": ("@method" "@authority" "@request-target" "content-digest");alg="rsa-v1_5-sha256";keyid="your-public-key-identifier";created=1675688690
This signature base is the string which will be signed, so you must respect the format (e.g., respect every character like ", @, or " " for instance).
The tables below explains how to build the signature base.
Key | Value |
---|---|
"@method" | The HTTP method, e.g. GET or POST . |
"@authority" | The HTTP host (a.k.a. authority): - api.numeral.io or mtls.api.numeral.io for the production environment- sandbox.numeral.io or mtls.sandbox.numeral.io for the sandbox environment |
"@request-target" | The full path (a.k.a request target) of the request, e.g. /v1/payment_orders .Query parameters must be included (e.g. /v1/connected_accounts?limit=7 ). |
"content-digest" | Only needs to be included if the API request has a body (e.g. for a POST request).The SHA-256 hash of the body, wrapped as follows: sha-256=:CONTENT_DIGEST: (replace CONTENT_DIGEST by the SHA-256 hash of the body, Base64-encoded).The key is "content-digest" , not "@content-digest" . |
"@signature-params" | The following signature parameters should be passed as follows and separated by commas:
|
Multiple key pairs can be used, for instance if multiple microservices need to access the API.
2.2. Sign the signature base with your private key
You should now be able to use your private key to sign the signature base with RSASSA-PKCS1-v1_5 using SHA-256 algorithm as follows:
- Hash the signature base using the SHA-256 algorithm
- Sign the result with RSASSA-PKCS1-v1_5 using your private key
- Encode the result using Base64
You will obtain the signature of your API request.
In order to prevent replay attacks, API signatures are valid for 5 minutes only.
2.3. Set the Signature
HTTP header
Signature
HTTP headerAdd the Signature
HTTP header to your API request. The value must respect the sig1=:SIGNATURE:
format, replacing SIGNATURE
with the signature obtained in the previous step. Here is an example:
Signature: sig1=:um7BBN7orBQtbprYizVVGGzHhMq4xQgDsveeUEgH9CsK5eY18zLDQ2PwuAoZjdPbQb+VcuWaQ1OLzFqwSUDpV7qNJCfNtc15CBMVrT6XZy0LTISv8T7fbMjSATFq9O3j5vSGLt4fStHj91JdEPW8KbymcnMQTq3W3M8yUqWPd3XgXgRnfflSZiDhT8g/5RJgAMfG5IBr4DnJlIrT8QkVfZZZfIG5X4ORry+B8OSxuMXMHdgQqArWX0ESuNrLi5p0aHoz8Par5K9Li2DdXIEpfyEfziXKUjJ+zL5vxqMocf162rNgW7AZlYK2nemLDufKK6yIU1UWDdZzSAwQB72kIg==:
2.4. Set the Signature-Input
HTTP header
Signature-Input
HTTP headerAdd the Signature-Input
HTTP header to your API request. The value must respect the sig1=SIGNATURE_INPUT
format, replacing SIGNATURE_INPUT
with the parameters of the signature obtained in step 2.1. Here is an example:
Signature-Input: sig1=("@method" "@authority" "@request-target" "content-digest");alg="rsa-v1_5-sha256";keyid="your-public-key-identifier";created=1675688690
2.5. Set the Content-Digest
HTTP header
Content-Digest
HTTP headerIf your API request has a body, add the Content-Digest
HTTP header to your API request. The value must respect the sha-256=:CONTENT_DIGEST:
format, replacing CONTENT_DIGEST
with the SHA-256 hash of the body, Base64-encoded following the principles detailed in step 2.1. Here is an example:
sha-256=:9AmU2hRsZnqEo2HiNTLacLN0fOs8YmDiuX4WYeYWvh0=:
Appendix
Code samples
You do not see your programming language below? Please contact us.
You first need to add a signature library to your application:
package signature
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"log"
"net/http"
"net/url"
"sort"
"strings"
)
func SignatureHeader(request http.Request, serializedBody []byte, keyID string, created int64, privateKey string) string {
// Build the signature base.
var optionalParam, optionalLine string
if len(serializedBody) != 0 {
optionalLine = fmt.Sprintf(`"content-digest": %s`, ContentDigestHeader(serializedBody)) + "\n"
optionalParam += ` "content-digest"`
}
signatureBase := fmt.Sprintf(`"@method": %s
"@authority": %s
"@request-target": %s
%s"@signature-params": %s`,
request.Method,
request.Host,
request.URL.RequestURI(),
optionalLine,
signatureParams(len(serializedBody) != 0, keyID, created),
)
// Sign the signature base.
signature, err := sha256AndSignPKCS1v15(loadPrivateKey(privateKey), []byte(signatureBase))
if err != nil {
log.Fatal(err)
}
return fmt.Sprintf("sig1=:%s:", signature)
}
func SignatureInputHeader(hasDigest bool, keyID string, created int64) string {
return fmt.Sprintf(`sig1=%s`, signatureParams(
hasDigest,
keyID,
created,
))
}
func ContentDigestHeader(serializedBody []byte) string {
hash := sha256.Sum256(serializedBody)
encodedHash := base64.StdEncoding.EncodeToString(hash[:])
return fmt.Sprintf("sha-256=:%s:", encodedHash)
}
func signatureParams(hasDigest bool, keyID string, created int64) string {
var optionalParam string
if hasDigest {
optionalParam += ` "content-digest"`
}
return fmt.Sprintf(`("@method" "@authority" "@request-target"%s);alg="rsa-v1_5-sha256";keyid="%s";created=%d`,
optionalParam,
keyID,
created,
)
}
func loadPrivateKey(pemString string) *rsa.PrivateKey {
block, _ := pem.Decode([]byte(pemString))
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
log.Fatal(err)
}
return privateKey
}
// sha256AndSignPKCS1v15 hashes the message using SHA256,
// then signs it using RSASSA-PKCS1-V1_5-SIGN from RSA PKCS #1 v1.5.
// It returns the signature as a base64 string.
func sha256AndSignPKCS1v15(privateKey *rsa.PrivateKey, message []byte) (string, error) {
hashed := sha256.Sum256(message)
signature, err := rsa.SignPKCS1v15(
rand.Reader,
privateKey,
crypto.SHA256,
hashed[:],
)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(signature), nil
}
func ReorderQueryParameters(originalURL string) (string, error) {
parsedURL, err := url.Parse(originalURL)
if err != nil {
return "", err
}
parsedQueryParams, err := url.ParseQuery(parsedURL.RawQuery)
if err != nil {
return "", err
}
// Sort the query parameters alphabetically by key name.
keys := make([]string, 0, len(parsedQueryParams))
for key := range parsedQueryParams {
keys = append(keys, key)
}
sort.Strings(keys)
// Construct the new query string.
var sortedQueryParams []string
for _, key := range keys {
values := parsedQueryParams[key]
for _, value := range values {
sortedQueryParams = append(sortedQueryParams, fmt.Sprintf("%s=%s", key, value))
}
}
// Create a new URL with the sorted query parameters
parsedURL.RawQuery = strings.Join(sortedQueryParams, "&")
return parsedURL.String(), nil
}
import base64
import collections
import urllib.parse
# From pycryptodome library.
from Crypto.Signature import PKCS1_v1_5
from Crypto.Hash import SHA256
from Crypto.PublicKey import RSA
def signature_header(
method: str,
host: str,
request_uri: str,
serialized_body: bytes,
key_id: str,
created: int,
private_key: str,
) -> str:
# Build the signature base.
optional_param, optional_line = "", ""
if serialized_body:
optional_line = '"content-digest": {}'.format(content_digest_header(serialized_body)) + "\n"
optional_param += ' "content-digest"'
signature_base = """"@method": {}
"@authority": {}
"@request-target": {}
{}"@signature-params": {}""".format(
method,
host,
request_uri,
optional_line,
_signature_params(serialized_body is not None, key_id, created),
)
signature = _sha256_and_sign_pkcs1v15(private_key, signature_base.encode())
return "sig1=:{}:".format(signature)
def signature_input_header(has_digest: bool, key_id: str, created: int) -> str:
return "sig1={}".format(_signature_params(has_digest, key_id, created))
def content_digest_header(serialized_body: bytes) -> str:
hashed = SHA256.new()
hashed.update(serialized_body)
return "sha-256=:{}:".format(base64.b64encode(hashed.digest()).decode())
def _signature_params(has_digest: bool, key_id: str, created: int) -> str:
optional_param = ""
if has_digest:
optional_param += ' "content-digest"'
return '("@method" "@authority" "@request-target"{});alg="rsa-v1_5-sha256";keyid="{}";created={}'.format(
optional_param,
key_id,
created,
)
def _sha256_and_sign_pkcs1v15(private_key: str, message: bytes) -> str:
hashed = SHA256.new()
hashed.update(message)
key = RSA.import_key(private_key)
signer = PKCS1_v1_5.new(key)
signature = signer.sign(hashed)
return base64.b64encode(signature).decode()
def reorder_query_parameters(url: str) -> str:
parsed_url = urllib.parse.urlparse(url)
parsed_query_params = urllib.parse.parse_qs(parsed_url.query)
sorted_params = collections.OrderedDict(sorted(parsed_query_params.items()))
new_query_string = urllib.parse.urlencode(sorted_params, doseq=True)
return urllib.parse.urlunparse((
parsed_url.scheme,
parsed_url.netloc,
parsed_url.path,
parsed_url.params,
new_query_string,
parsed_url.fragment,
))
<?php
/**
* Performs a signed HTTP request.
*
* @param string $method The HTTP method (e.g., "POST").
* @param string $url The URL to which the request is made.
* @param string $serializedBody The serialized request body.
* @param string $apiKey The API key for authentication.
* @param string $publicKeyID The public key ID used for signing the request.
* @param int $timestamp The current timestamp.
*/
function doSignedRequest($method, $url, $serializedBody, $apiKey, $publicKeyID, $timestamp) {
// Reorder query parameters alphabetically for consistent signing
$url = reorderQueryParameters($url);
// Initialize cURL session
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_POSTFIELDS, $serializedBody);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
// Enable verbose output for debugging purposes
$verbose = fopen('php://temp', 'w+');
curl_setopt($ch, CURLOPT_VERBOSE, true);
curl_setopt($ch, CURLOPT_STDERR, $verbose);
// Generate signature input and signature headers
$signatureInputHeader = signatureInputHeader($serializedBody, $publicKeyID, $timestamp);
$signatureHeader = signatureHeader($method, $url, $serializedBody, $publicKeyID, $timestamp);
// Prepare HTTP headers for the request
$headers = [
"Content-Type: application/json",
"Numeral-Api-Version: 2022-01-01",
"X-Api-Key: $apiKey",
"Signature-Input: $signatureInputHeader",
"Signature: $signatureHeader",
"User-Agent: Numeral", // Custom user agent
];
// Include Content-Digest header if request body is not empty
if (!empty($serializedBody)) {
$contentDigestHeader = contentDigestHeader($serializedBody);
$headers[] = "Content-Digest: $contentDigestHeader";
}
// Set HTTP headers for the cURL request
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
// Execute the cURL session
$response = curl_exec($ch);
if ($response === false) {
// Output cURL error and verbose log for debugging
fseek($verbose, 0); // Seek to the start of verbose output
$verboseLog = stream_get_contents($verbose);
echo "cURL Error: " . curl_error($ch) . "\nVerbose Output:\n" . $verboseLog;
} else {
// Output the response
echo $response;
}
// Close resources
fclose($verbose);
curl_close($ch);
}
/**
* Reorders query parameters alphabetically.
*
* @param string $url The URL with query parameters to reorder.
* @return string The URL with reordered query parameters.
*/
function reorderQueryParameters($url) {
$parsedUrl = parse_url($url);
if (!isset($parsedUrl['query'])) return $url;
parse_str($parsedUrl['query'], $queryParams);
ksort($queryParams);
$queryString = http_build_query($queryParams);
return $parsedUrl['scheme'] . '://' . $parsedUrl['host'] . (isset($parsedUrl['path']) ? $parsedUrl['path'] : '') . '?' . $queryString;
}
/**
* Generates the Signature header value.
*
* @param string $method The HTTP method.
* @param string $url The request URL.
* @param string $serializedBody The request body.
* @param string $publicKeyID The public key ID.
* @param int $timestamp The timestamp.
* @return string The Signature header value.
*/
function signatureHeader($method, $url, $serializedBody, $publicKeyID, $timestamp) {
$parsedUrl = parse_url($url);
$requestUri = $parsedUrl['path'] . (isset($parsedUrl['query']) ? '?' . $parsedUrl['query'] : '');
// Construct the signature base string
$methodPart = '"@method": ' . strtoupper($method) . "\n";
$authorityPart = '"@authority": ' . $parsedUrl['host'] . "\n";
$requestTargetPart = '"@request-target": ' . $requestUri . "\n";
$contentDigestPart = !empty($serializedBody) ? '"content-digest": ' . contentDigestHeader($serializedBody) . "\n" : '';
$signatureParamsPart = '"@signature-params": ' . signatureParams(!empty($serializedBody), $publicKeyID, $timestamp);
// Concatenate parts to form the base string
$signatureBase = $methodPart . $authorityPart . $requestTargetPart . $contentDigestPart . $signatureParamsPart;
// Sign the base string and encode in base64
return 'sig1=:' . sha256AndSignPKCS1v15($signatureBase) . ':';
}
/**
* Generates the Signature-Input header value.
*
* @param string $serializedBody The request body.
* @param string $publicKeyID The public key ID.
* @param int $timestamp The timestamp.
* @return string The Signature-Input header value.
*/
function signatureInputHeader($serializedBody, $publicKeyID, $timestamp) {
return 'sig1=' . signatureParams($serializedBody, $publicKeyID, $timestamp);
}
/**
* Constructs the signature parameters string.
*
* @param string $serializedBody The request body.
* @param string $publicKeyID The public key ID.
* @param int $timestamp The timestamp.
* @return string The formatted signature parameters.
*/
function signatureParams($serializedBody, $publicKeyID, $timestamp) {
$optionalParam = !empty($serializedBody) ? ' "content-digest"' : '';
return sprintf('("@method" "@authority" "@request-target"%s);alg="rsa-v1_5-sha256";keyid="%s";created=%d',
$optionalParam, $publicKeyID, $timestamp);
}
/**
* Generates the Content-Digest header value.
*
* @param string $serializedBody The request body.
* @return string The Content-Digest header value.
*/
function contentDigestHeader($serializedBody) {
$hash = hash('sha256', $serializedBody, true);
return 'sha-256=:' . base64_encode($hash) . ':';
}
/**
* Signs a message with RSA-SHA256 and encodes the signature in base64.
*
* @param string $message The message to sign.
* @return string The base64-encoded signature.
*/
function sha256AndSignPKCS1v15($message) {
// Load the private key
$privateKey = openssl_get_privatekey(file_get_contents("./private_key.pem"));
if (!$privateKey) {
die("Failed to load private key.");
}
// Sign the message
openssl_sign($message, $signature, $privateKey, OPENSSL_ALGO_SHA256);
// Return the base64-encoded signature
return base64_encode($signature);
}
import * as crypto from 'crypto';
import { IncomingHttpHeaders, request as httpRequest, RequestOptions } from 'http';
import * as https from 'https';
import * as url from 'url';
function loadPrivateKey(pemString: string): crypto.KeyObject {
return crypto.createPrivateKey(pemString);
}
function sha256AndSignPKCS1v15(privateKey: crypto.KeyObject, message: string): string {
const signer = crypto.createSign('sha256');
signer.update(message);
signer.end();
const signature = signer.sign(privateKey, 'base64');
return signature;
}
export function contentDigestHeader(serializedBody: string): string {
const hash = crypto.createHash('sha256').update(serializedBody).digest('base64');
return `sha-256=:${hash}:`;
}
export function signatureParams(hasDigest: boolean, keyID: string, created: number): string {
let optionalParam = hasDigest ? ' "content-digest"' : '';
return `("@method" "@authority" "@request-target"${optionalParam});alg="rsa-v1_5-sha256";keyid="${keyID}";created=${created}`;
}
export function signatureHeader(requestOptions: RequestOptions, serializedBody: string, keyID: string, created: number, privateKey: string): string {
const privateKeyObject = loadPrivateKey(privateKey);
const hasDigest = serializedBody.length > 0;
const optionalLine = hasDigest ? `"content-digest": ${contentDigestHeader(serializedBody)}\n` : '';
const signatureBase = `"@method": ${requestOptions.method}
"@authority": ${requestOptions.hostname}
"@request-target": ${requestOptions.path}
${optionalLine}"@signature-params": ${signatureParams(hasDigest, keyID, created)}`;
const signature = sha256AndSignPKCS1v15(privateKeyObject, signatureBase);
return `sig1=:${signature}:`;
}
export function reorderQueryParameters(originalUrl: string): string {
const parsedUrl = new url.URL(originalUrl);
const sortedSearchParams = new url.URLSearchParams(parsedUrl.search);
sortedSearchParams.sort();
parsedUrl.search = sortedSearchParams.toString();
return parsedUrl.toString();
}
const crypto = require('crypto');
function reorderQueryParameters(urlString) {
const url = new URL(urlString);
const params = new URLSearchParams(url.searchParams);
const sortedParams = new URLSearchParams([...params.entries()].sort());
url.search = sortedParams.toString();
return url.toString();
}
function generateSignatureInputHeader(hasDigest, keyID, created) {
let params = `("@method" "@authority" "@request-target"${hasDigest ? ' "content-digest"' : ''});alg="rsa-v1_5-sha256";keyid="${keyID}";created=${created}`;
return params;
}
function generateContentDigestHeader(serializedBody) {
const hash = crypto.createHash('sha256').update(serializedBody).digest('base64');
return `sha-256=:${hash}:`;
}
async function generateSignatureHeader(method, url, serializedBody, keyID, created, privateKey) {
let optionalLine = serializedBody ? `"content-digest": ${generateContentDigestHeader(serializedBody)}\n` : '';
let signatureBase = `"@method": ${method}
"@authority": ${url.host}
"@request-target": ${url.pathname + url.search}
${optionalLine}"@signature-params": ${generateSignatureInputHeader(!!serializedBody, keyID, created)}`;
const signature = sha256AndSignPKCS1v15(privateKey, signatureBase);
return `sig1=:${signature}:`;
}
function sha256AndSignPKCS1v15(privateKeyPem, message) {
const privateKey = crypto.createPrivateKey(privateKeyPem);
const sign = crypto.createSign('sha256');
sign.update(message);
sign.end();
const signature = sign.sign(privateKey, 'base64');
return signature;
}
module.exports = {
reorderQueryParameters,
generateSignatureInputHeader,
generateContentDigestHeader,
generateSignatureHeader,
};
require 'openssl'
require 'base64'
require 'uri'
require 'net/http'
module Signature
# Generates the Signature Header
def self.signature_header(full_uri, request, serialized_body, key_id, created, private_key_pem)
# Calculate the Content Digest
content_digest = content_digest_header(serialized_body) unless serialized_body.empty?
optional_line = serialized_body.empty? ? "" : "\"content-digest\": #{content_digest}\n"
optional_param = serialized_body.empty? ? "" : " \"content-digest\""
signature_base = <<~SIGBASE
"@method": #{request.method}
"@authority": #{URI.parse(full_uri).host}
"@request-target": #{request.path}
#{optional_line}"@signature-params": #{signature_params(!serialized_body.empty?, key_id, created)}
SIGBASE
# Sign the signature base
signature = sha256_and_sign_pkcs1_v15(private_key_pem, signature_base)
"sig1=:%s:" % signature
end
# Generates the Signature Input Header
def self.signature_input_header(has_digest, key_id, created)
"sig1=#{signature_params(has_digest, key_id, created)}"
end
# Generates the Content Digest Header
def self.content_digest_header(serialized_body)
digest = OpenSSL::Digest::SHA256.new.digest(serialized_body)
"sha-256=:%s:" % Base64.strict_encode64(digest)
end
def self.reorder_query_parameters(original_url)
return original_url if original_url.nil? || original_url.empty?
uri = URI.parse(original_url)
return original_url unless uri.query # Return if there are no query parameters to sort
query_params = URI.decode_www_form(uri.query).sort
uri.query = URI.encode_www_form(query_params)
uri.to_s
rescue URI::InvalidURIError => e
puts "Invalid URI: #{e.message}"
original_url # Return the original URL if there's an error
end
private
# Helper to generate signature parameters
def self.signature_params(has_digest, key_id, created)
optional_param = has_digest ? ' "content-digest"' : ''
'("@method" "@authority" "@request-target"%s);alg="rsa-v1_5-sha256";keyid="%s";created=%d' % [optional_param, key_id, created]
end
# Loads the RSA private key and signs the signature base
def self.sha256_and_sign_pkcs1_v15(private_key_pem, message)
message = message.chomp
private_key = OpenSSL::PKey::RSA.new(private_key_pem)
signature = private_key.sign(OpenSSL::Digest::SHA256.new, message)
Base64.strict_encode64(signature)
end
end
import java.net.URI;
import java.net.URISyntaxException;
import java.security.*;
import java.util.*;
import javax.crypto.*;
import java.util.Base64;
import java.util.Arrays;
import java.util.Comparator;
import java.util.stream.Collectors;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.InvalidKeySpecException;
import java.nio.charset.StandardCharsets;
import java.net.URLDecoder;
import java.net.URLEncoder;
public class SignatureUtil {
public static String generateSignature(String method, String uri, String body, String keyID, long created, PrivateKey privateKey) throws Exception {
URI parsedUri = new URI(uri);
String authority = parsedUri.getHost();
String requestTarget = parsedUri.getRawPath();
String contentDigest = !body.isEmpty() ? generateContentDigest(body) : "";
String signatureInput = generateSignatureParams(!body.isEmpty(), keyID, created);
// Building the signature base string without adding a blank line if the content digest is empty
String signatureBase = String.format(
"\"@method\": %s\n\"@authority\": %s\n\"@request-target\": %s%s\n\"@signature-params\": %s",
method.toUpperCase(),
authority,
requestTarget,
(!contentDigest.isEmpty() ? "\n\"content-digest\": " + contentDigest : ""),
signatureInput
);
return sign(signatureBase, privateKey);
}
public static String generateSignatureInput(boolean hasDigest, String keyID, long created) {
return String.format(
"sig1=%s",
generateSignatureParams(hasDigest, keyID, created)
);
}
public static String generateSignatureParams(boolean hasDigest, String keyID, long created) {
// Include quotes around each header name as per the provided format
String headerNames = hasDigest ? "\"@method\" \"@authority\" \"@request-target\" \"content-digest\"" : "\"@method\" \"@authority\" \"@request-target\"";
return String.format(
"(%s);alg=\"rsa-v1_5-sha256\";keyid=\"%s\";created=%d",
headerNames,
keyID,
created
);
}
public static String generateContentDigest(String body) throws NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(body.getBytes());
return "sha-256=:" + Base64.getEncoder().encodeToString(hash) + ":";
}
private static String sign(String signatureBase, PrivateKey privateKey) throws NoSuchAlgorithmException, InvalidKeyException, SignatureException {
Signature signature = Signature.getInstance("SHA256withRSA");
signature.initSign(privateKey);
signature.update(signatureBase.getBytes());
byte[] signed = signature.sign();
return Base64.getEncoder().encodeToString(signed);
}
public static PrivateKey loadPrivateKey(String pemString) throws GeneralSecurityException {
pemString = pemString.replace("-----BEGIN PRIVATE KEY-----", "").replace("-----END PRIVATE KEY-----", "");
pemString = pemString.replaceAll("\\s+", "");
byte[] pkcs8EncodedBytes = Base64.getDecoder().decode(pemString);
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(pkcs8EncodedBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return keyFactory.generatePrivate(keySpec);
}
}
#!/bin/bash
# Usage: ./script.sh <api_key> <public_key_id> <private_key_path>
# Check for required arguments
if [ "$#" -ne 3 ]; then
echo "Usage: $0 <api_key> <public_key_id> <private_key_path>"
exit 1
fi
# Parameters from command line arguments
API_KEY="$1"
PUBLIC_KEY_ID="$2"
PRIVATE_KEY_PATH="$3"
# Fixed parameters
METHOD="POST"
URL="https://sandbox.numeral.io/v1/payment_orders"
TIMESTAMP=$(date +%s)
BODY='{
"type": "sepa",
"direction": "credit",
"amount": 315,
"currency": "EUR",
"connected_account_id": "replace this",
"receiving_account": {
"account_number": "replace this",
"bank_code": "replace this",
"holder_name": "replace this",
"holder_address": {
"country": "FR"
}
},
"reference": "example"
}'
# Check if the private key file exists
if [[ ! -f "$PRIVATE_KEY_PATH" ]]; then
echo "Private key file not found at path: $PRIVATE_KEY_PATH"
exit 1
fi
# Function to create content-digest header
create_content_digest() {
echo -n "$BODY" | openssl dgst -sha256 -binary | openssl base64
}
# Generate content digest
CONTENT_DIGEST=$(create_content_digest)
# Generate signature base
AUTHORITY=$(echo $URL | awk -F/ '{print $3}')
REQUEST_TARGET=$(echo $URL | sed -e 's,^[^/]*//[^/]*/,/,')
SIGNATURE_BASE=$(printf "\"@method\": %s\n\"@authority\": %s\n\"@request-target\": %s\n\"content-digest\": sha-256=:%s:\n\"@signature-params\": (\"@method\" \"@authority\" \"@request-target\" \"content-digest\");alg=\"rsa-v1_5-sha256\";keyid=\"%s\";created=%d" "$METHOD" "$AUTHORITY" "$REQUEST_TARGET" "$CONTENT_DIGEST" "$PUBLIC_KEY_ID" "$TIMESTAMP")
# Sign the signature base
SIGNATURE=$(echo -n "$SIGNATURE_BASE" | openssl dgst -sha256 -sign "$PRIVATE_KEY_PATH" | openssl base64 | tr -d '\n')
SIGNATURE_HEADER="sig1=:$SIGNATURE:"
# Create headers
SIGNATURE_INPUT="sig1=(\"@method\" \"@authority\" \"@request-target\" \"content-digest\");alg=\"rsa-v1_5-sha256\";keyid=\"$PUBLIC_KEY_ID\";created=$TIMESTAMP"
CONTENT_DIGEST_HEADER="sha-256=:$CONTENT_DIGEST:"
# Enable verbose output
set -x
# Make the request
curl -X $METHOD $URL \
-H "Content-Type: application/json" \
-H "X-Api-Key: $API_KEY" \
-H "Content-Digest: $CONTENT_DIGEST_HEADER" \
-H "Signature-Input: $SIGNATURE_INPUT" \
-H "Signature: $SIGNATURE_HEADER" \
-d "$BODY"
You can then use this code sample to add the additional HTTP headers to your API requests:
package main
import (
"bytes"
"io"
"log"
"net/http"
"time"
"signatures/signature"
)
func main() {
doSignedRequest(
"POST",
"https://api.numeral.io/v1/payment_orders",
[]byte(`{
"type": "sepa",
"direction": "credit",
"amount": 315,
"currency": "EUR",
"connected_account_id": "replace this",
"receiving_account": {
"account_number": "replace this",
"bank_code": "replace this",
"holder_name": "replace this",
"holder_address": {
"country": "FR"
}
},
"reference": "example"
}`),
"replace this by your API key",
"replace this by your public key identifier",
"replace this by your private key in PEM format",
time.Now().UTC().Unix(),
)
}
func doSignedRequest(
method string,
url string,
serializedBody []byte,
apiKey string,
publicKeyID string,
privateKey string,
timestamp int64,
) {
url, err := signature.ReorderQueryParameters(url)
if err != nil {
log.Fatal(err)
}
// Create the request.
client := &http.Client{}
request, err := http.NewRequest(method, url, bytes.NewReader(serializedBody))
if err != nil {
log.Fatal(err)
}
// Set headers.
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Api-Key", apiKey)
signatureInputHeader := signature.SignatureInputHeader(
len(serializedBody) != 0,
publicKeyID,
timestamp,
)
request.Header.Set("Signature-Input", signatureInputHeader)
signatureHeader := signature.SignatureHeader(
*request,
serializedBody,
publicKeyID,
timestamp,
privateKey,
)
request.Header.Set("Signature", signatureHeader)
if len(serializedBody) != 0 {
contentDigestHeader := signature.ContentDigestHeader(serializedBody)
request.Header.Set("Content-Digest", contentDigestHeader)
}
response, err := client.Do(request)
if err != nil {
log.Fatal(err)
}
defer response.Body.Close()
_, err = io.ReadAll(response.Body)
if err != nil {
log.Fatal(err)
}
}
import datetime
import json
import requests
import urllib.parse
import signature # The small library provided by Numeral.
def do_signed_request(
method: str,
url: str,
serialized_body: bytes | None,
api_key: str,
public_key_id: str,
private_key: str,
timestamp: int,
) -> requests.Response:
url = signature.reorder_query_parameters(url)
signature_input_header = signature.signature_input_header(
has_digest=serialized_body is not None,
key_id=public_key_id,
created=timestamp,
)
parsed_url = urllib.parse.urlparse(url)
signature_header = signature.signature_header(
method=method,
host=parsed_url.hostname,
request_uri=url.split(parsed_url.hostname, 1)[1],
serialized_body=serialized_body,
key_id=public_key_id,
created=timestamp,
private_key=private_key,
)
headers = {
"Content-Type": "application/json",
"X-Api-Key": api_key,
"Signature": signature_header,
"Signature-Input": signature_input_header,
}
if serialized_body is not None:
headers["Content-Digest"] = signature.content_digest_header(serialized_body)
return requests.request(
method=method,
url=url,
headers=headers,
data=serialized_body,
)
def _utc_now_timestamp() -> int:
now = datetime.datetime.now(datetime.timezone.utc)
utc_time = now.replace(tzinfo=datetime.timezone.utc)
return int(utc_time.timestamp())
if __name__ == '__main__':
do_signed_request(
method="POST",
url="https://api.numeral.io/v1/payment_orders",
serialized_body=json.dumps({
"type": "sepa",
"direction": "credit",
"amount": 315,
"currency": "EUR",
"connected_account_id": "replace this",
"receiving_account": {
"account_number": "replace this",
"bank_code": "replace this",
"holder_name": "replace this",
"holder_address": {
"country": "FR"
}
},
"reference": "example"
}).encode(),
api_key="replace this by your API key",
public_key_id="replace this by your public key identifier",
private_key="replace this by your private key in PEM format",
timestamp=_utc_now_timestamp(),
)
<?php
require_once 'signature.php'; // Adjust the path as necessary
// Your JSON payload or other data
$rawJson = <<<JSON
{
"type": "sepa",
"direction": "credit",
"amount": 315,
"currency": "EUR",
"connected_account_id": "replace this",
"receiving_account": {
"account_number": "replace this",
"bank_code": "replace this",
"holder_name": "replace this",
"holder_address": { "country": "FR" }
},
"reference": "numeral PHP"
}
JSON;
// Example usage of the doSignedRequest function
doSignedRequest(
"POST",
"https://api.numeral.io/v1/payment_orders",
$rawJson, // Pass your JSON payload here
"put_your_api_key_here", // API key
"put_your_public_key_id_here", // Public key ID
time() // Current timestamp
);
import * as https from 'https';
import { signatureHeader, reorderQueryParameters, contentDigestHeader, signatureParams } from './signature';
function doSignedRequest(
method: string,
uri: string,
serializedBody: string,
apiKey: string,
publicKeyID: string,
privateKey: string,
timestamp: number
): void {
const sortedUri = reorderQueryParameters(uri);
const headers: https.RequestOptions['headers'] = {
"Content-Type": "application/json",
"X-Api-Key": apiKey,
};
if (serializedBody) {
headers["Content-Digest"] = contentDigestHeader(serializedBody);
}
// Prepare Signature-Input and Signature headers
const signatureInputHeader = `sig1=${signatureParams(true, publicKeyID, timestamp)}`;
headers["Signature-Input"] = signatureInputHeader;
headers["Signature"] = signatureHeader({ method, hostname: new URL(uri).hostname, path: new URL(uri).pathname }, serializedBody, publicKeyID, timestamp, privateKey);
const options: https.RequestOptions = {
hostname: new URL(uri).hostname,
port: 443,
path: new URL(uri).pathname + new URL(uri).search,
method: method,
headers: headers,
};
const req = https.request(options, (res) => {
console.log(`Status Code: ${res.statusCode}`);
res.on('data', (d) => {
process.stdout.write(d);
});
});
req.on('error', (e) => {
console.error(e);
});
if (serializedBody) {
req.write(serializedBody);
}
req.end();
}
// Example usage, replace placeholders with actual values
doSignedRequest(
"POST",
"https://api.numeral.io/v1/payment_orders",
JSON.stringify({
"type": "sepa",
"direction": "credit",
"amount": 315,
"currency": "EUR",
"connected_account_id": "replace this",
"receiving_account": {
"account_number": "replace this",
"bank_code": "replace this",
"holder_name": "replace this",
"holder_address": {
"country": "FR"
}
},
"reference": "example"
}),
"replace this by your API key",
"replace this by your public key identifier",
"replace this by your private key in PEM format",
Math.floor(Date.now() / 1000)
);
const {
reorderQueryParameters,
generateSignatureInputHeader,
generateContentDigestHeader,
generateSignatureHeader,
} = require('./signature');
const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
const { URL, URLSearchParams } = require('url');
const defaultPublicKeyID = 'Replace this with your public key ID';
const defaultAPIKey = 'Replace this with your API key';
const defaultPrivateKey = () => `Replace this with your private key in PEM format`;
async function main() {
await doPostSignedRequest();
}
async function doPostSignedRequest() {
await doSignedRequest(
'POST',
'https://api.numeral.io/v1/payment_orders',
JSON.stringify({
type: 'sepa',
direction: 'credit',
amount: 315,
currency: 'EUR',
connected_account_id: 'replace this',
receiving_account: {
account_number: 'replace this',
bank_code: 'replace this',
holder_name: 'replace this',
"holder_address": {
"country": "FR",
},
},
reference: 'example'
}),
defaultAPIKey,
defaultPublicKeyID,
defaultPrivateKey(),
Math.floor(Date.now() / 1000) // Unix timestamp
);
}
async function doSignedRequest(method, urlString, serializedBody, apiKey, publicKeyID, privateKey, timestamp) {
const url = new URL(reorderQueryParameters(urlString));
const signatureInputHeader = generateSignatureInputHeader(!!serializedBody, publicKeyID, timestamp);
const signatureHeader = await generateSignatureHeader(method, url, serializedBody, publicKeyID, timestamp, privateKey);
const contentDigestHeader = serializedBody ? generateContentDigestHeader(serializedBody) : null;
const headers = {
'Content-Type': 'application/json',
'X-Api-Key': apiKey,
'Signature-Input': `sig1=${signatureInputHeader}`,
'Signature': signatureHeader,
};
if (contentDigestHeader) {
headers['Content-Digest'] = contentDigestHeader;
}
try {
const response = await fetch(url.toString(), {
method: method,
headers: headers,
body: method !== 'GET' ? serializedBody : undefined
});
const responseBody = await response.text();
// Log the inputs and outputs
console.log('--- Signature input header:', signatureInputHeader);
console.log('--- Signature header:', signatureHeader);
console.log('--- Content digest header:', contentDigestHeader);
console.log("--- Request body:", serializedBody);
console.log('--- Status code:', response.status);
console.log('--- Response body:', responseBody);
} catch (error) {
console.error('Request failed:', error);
}
}
main().catch(console.error);
require 'net/http'
require 'uri'
require 'time'
require_relative 'signature'
def do_signed_request(method, uri, serialized_body, api_key, public_key_id, private_key, timestamp)
u = URI.parse(Signature.reorder_query_parameters(uri))
http = Net::HTTP.new(u.host, u.port)
http.use_ssl = true if u.scheme == "https"
puts "Sending request to #{u.request_uri}"
request = Net::HTTP::Post.new(u.request_uri, {
'Content-Type' => 'application/json',
'X-Api-Key' => api_key,
})
signature_input_header = Signature.signature_input_header(!serialized_body.empty?, public_key_id, timestamp)
request['Signature-Input'] = signature_input_header
signature_header = Signature.signature_header(u.to_s, request, serialized_body, public_key_id, timestamp, private_key)
request['Signature'] = signature_header
request.body = serialized_body unless serialized_body.empty?
request['Content-Digest'] = Signature.content_digest_header(serialized_body) unless serialized_body.empty?
response = http.request(request)
puts response.body
rescue => e
puts "Request failed: #{e}"
end
# Example usage
do_signed_request(
"POST",
"https://api.numeral.io/v1/payment_orders",
'{
"type": "sepa",
"direction": "credit",
"amount": 315,
"currency": "EUR",
"connected_account_id": "replace this",
"receiving_account": {
"account_number": "replace this",
"bank_code": "replace this",
"holder_name": "replace this",
"holder_address": {
"country": "FR"
}
},
"reference": "example"
}',
"replace this by your API key",
"replace this by your public key identifier",
"replace this by your private key in PEM format",
Time.now.to_i
)
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.PrivateKey;
import java.security.Security;
public class Main {
public static void main(String[] args) {
try {
String method = "POST";
String uri = "https://api.numeral.io/v1/payment_orders";
String body = """
{
"type": "sepa",
"direction": "credit",
"amount": 315,
"currency": "EUR",
"connected_account_id": "replace this",
"receiving_account": {
"account_number": "replace this",
"bank_code": "replace this",
"holder_name": "replace this",
"holder_address": {
"country": "FR"
}
},
"reference": "example"
}""";
String apiKey = "replace this by your API key";
String publicKeyId = "replace this by your public key identifier";
PrivateKey privateKey = SignatureUtil.loadPrivateKey("replace this by your private key in PEM format");
long timestamp = System.currentTimeMillis() / 1000;
// Generate the "Signature" and "Signature-Input" headers
String signature = SignatureUtil.generateSignature(method, uri, body, publicKeyId, timestamp, privateKey);
String signatureInput = SignatureUtil.generateSignatureInput(!body.isEmpty(), publicKeyId, timestamp);
URL url = new URL(uri);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod(method);
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Numeral-Api-Version", "2023-04-12");
conn.setRequestProperty("X-Api-Key", apiKey);
conn.setRequestProperty("Signature-Input", signatureInput);
conn.setRequestProperty("Signature", "sig1=:" + signature + ":");
conn.setDoOutput(true);
if (!body.isEmpty()) {
conn.setRequestProperty("Content-Digest", SignatureUtil.generateContentDigest(body));
try (OutputStream os = conn.getOutputStream()) {
os.write(body.getBytes());
}
}
// Read response
int status = conn.getResponseCode();
InputStream inputStream = (status >= HttpURLConnection.HTTP_BAD_REQUEST) ? conn.getErrorStream() : conn.getInputStream();
BufferedReader br = new BufferedReader(new InputStreamReader(inputStream));
String line;
StringBuilder response = new StringBuilder();
while ((line = br.readLine()) != null) {
response.append(line);
}
System.out.println("HTTP Status: " + status);
System.out.println("Response: " + response.toString());
} catch (Exception e) {
e.printStackTrace();
}
}
}
$ ./signature.sh <api_key> <public_key_id> <private_key_path>
API responses
API requests that are correctly signed will be processed normally. API requests that are not correctly signed will return a 400 - Bad Request
or 401 - Unauthorized
status code.
Invalid Signature HTTP header
If the Signature
HTTP header is invalid, the API will return a 400 - Bad Request
status code and the following body. This can happen if you did not send any Signature
HTTP header or if its format is invalid.
{
"error": "invalid_request",
"message": "invalid Signature header"
}
Invalid Signature-Input HTTP header
If the Signature-Input
HTTP header is invalid, the API will return a 400 - Bad Request
status code and the following body. This can happen if you did not send any Signature-Input
HTTP header or if its format is invalid.
{
"error": "invalid_request",
"message": "invalid Signature-Input header"
}
Invalid signature parameters
If the Signature-Input
HTTP header contains invalid parameters, the API will return a 400 - Bad Request
status code and the following body. This can happen if you sent an invalid created
timestamp or used an invalid keyid
for instance.
{
"error": "invalid_request",
"message": "unable to verify signature parameters"
}
Invalid signature
If the API is not able to verify the signature you sent, it will return a 401 - Unauthorized
status code and the following body.
{
"error": "unauthorized",
"message": "invalid signature"
}