Signing API requests

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, that you will use to sign your API requests
  • A public key, that Numeral will use to verify your API requests

Examples below assume that you can use the openssl program, using a terminal on a Unix system.

1.1. Create the private key

openssl genrsa -out private_key.pem 2048

This command will generate and store your private key in a file named private_key.pem, in PEM format.

1.2. Export the corresponding public key

openssl rsa -in private_key.pem -pubout > public_key.pem

This command will export the corresponding public key in a file named public_key.pem, in PEM format.

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's identifier

After receiving and storing your public key, Numeral will provide you with the public key's 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

This section explains how to sign your API requests using the RFC standard.

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.

KeyValue
"@method"The HTTP method, e.g. GET or POST.
"@authority"The HTTP host (a.k.a. authority), api.numeral.io for production environment or sandbox.numeral.io for 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:
  • "@method" "@authority" "@request-target" for requests without a body or "@method" "@authority" "@request-target" "content-digest") for requests with a body

  • alg: the algorithm used to sign the request with your private key. Only "rsa-v1_5-sha256" is currently supported

  • keyid: the identifier of your public key (e.g., 2fae2e24-fc1a-40d3-bb2a-5dc3a1f5c726). If you have multiple private and public keys, make sure to use the identifier related to the private key used to sign the request

  • created: a Unix timestamp (e.g. 1675688690). You must use UTC times and your server time must be synchronized using NTP for instance

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.

2.3. Set the Signature HTTP header

Add 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

Add 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

If 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"

	"github.com/pkg/errors"
)

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(errors.Wrap(err, "failed to create signature"))
	}

	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(errors.Wrap(err, "failed to parse private privateKey"))
	}
	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
}
import base64

# 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()

You can then use this code sample to add the additional HTTP headers to your API requests:

package main

import "signature"

func main() {
	// The request which will be signed.
	// NB: this code sample only shows how to add HTTP headers related to the signature.
	var request http.Request

	// Add the additional HTTP headers:
	timestamp := time.Now().UTC().Unix()
	request.Header.Set("Signature-Input", signature.SignatureInputHeader(
		false, // or true if the request has a body
		"replace this by your public key identifier",
		timestamp,
	))
	request.Header.Set("Signature", signature.SignatureHeader(
		*request,
		[]byte(`{"amount": 315}`), // or nil if no request's body
		"replace this by your public key identifier",
		timestamp,
		"replace this by your private key in PEM format",
	))
	request.Header.Set("Content-Digest", signature.ContentDigestHeader(
		"replace this by the request's body", // Do not include this header if no body.
	))
}
import datetime
import json

import requests
from urllib.parse import urlparse

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:
    signature_input_header = signature.signature_input_header(
        has_digest=serialized_body is not None,
        key_id=public_key_id,
        created=timestamp,
    )

    parsed_url = 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(),
    )

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"
}