Setup
Configure your webhook URL through the dashboard or programmatically via the Update Webhook URL endpoint. Your endpoint must be a publicly accessiblePOST URL that returns a 200 status code immediately. Process events asynchronously after acknowledging receipt.
Receiving events
Every webhook payload includes three fields:| Field | Type | Description |
|---|---|---|
event | string | The event type (e.g., Deposit, Convert, Transfer) |
data | object | Event-specific payload |
signature | string | RSA signature for verification |
Supported events
| Event | Description |
|---|---|
Deposit | A fiat or crypto deposit was received |
Convert | A currency conversion completed |
Transfer | A transfer (payout/withdrawal) status changed |
Onramp | An onramp (fiat-to-crypto) request status changed |
Withdrawal | A withdrawal was processed |
Offramp | An offramp (crypto-to-fiat) request status changed |
verification.success | A user successfully completed KYC verification |
verification.failed | A user failed KYC verification (includes reason) |
Sample events
{
"event": "Deposit",
"data": {
"amount": 5001,
"toUser": "chng",
"currency": "NGN",
"toAccount": "chng",
"status": "completed",
"sessionId": "3c58db9",
"transactionMemo": "Mock Deposit"
},
"signature": "m9STu6pcsviYMfgu5JBjuR"
}
{
"event": "convert",
"data": {
"transactionReference": "a1fe4aa5679d7",
"fromUser": "chng",
"toAccount": "chng",
"fromAmount": 5,
"toAmount": 6000.05,
"fromCurrency": "USD",
"toCurrency": "NGN"
},
"signature": "gTZdvoadZscP3g=="
}
{
"event": "Transfer",
"data": {
"transactionReference": "a16db1274783",
"amount": 1,
"fromUser": "chngr",
"toUser": "chngr",
"currency": "USD",
"fromAccount": "chngr",
"toAccount": "rhmn",
"status": "completed"
},
"signature": "C73lZKC80Cg=="
}
{
"event": "Onramp",
"data": {
"transactionReference": "a1e681b09cc7ccea",
"toUser": "chngr",
"toAccount": "chngr",
"status": "completed",
"sessionId": "52567c9fb6864a8c94549675c70cc9a8",
"fromAmount": 149775,
"toAmount": 109.99175768162324,
"fromCurrency": "NGN",
"toCurrency": "USDT",
"transactionMemo": "Mock Deposit"
},
"signature": "Wh+Jqnxc2Iw=="
}
{
"event": "Withdrawal",
"data": {
"transactionReference": "a1e687ccea",
"amount": 109.9917,
"fromUser": "chang",
"currency": "USDT",
"network": "tron",
"fromAccount": "chang",
"toAddress": "TSqRgHDE1Nypqo1LUp6Q",
"status": "processing",
"transactionHash": "3fa07c748260f469896bf"
},
"signature": "dC8YIklLc08uiGxA=="
}
{
"event": "Offramp",
"data": {
"transactionReference": "691af31ba66789",
"toUser": "chng",
"toAccount": "chng",
"status": "completed",
"transactionHash": "99d7c7b5f8f3447d0cc20279f8bab2f827558c5b76",
"fromAmount": 84.2,
"toAmount": 99879.3927,
"fromCurrency": "USDT",
"toCurrency": "NGN"
},
"signature": "faUgOe8SYUfBlvRWsDxg=="
}
Verifying signatures
Every webhook includes an RSA signature. Always verify the signature before processing an event to confirm it came from Partna. The signature is generated by signing the JSON-encodeddata field with Partna’s RSA private key using SHA-256 with PSS padding. Verify it against the corresponding public key for your environment.
import crypto from "crypto";
const SAMPLE_WEBHOOK_REQUEST = `{"event":"Convert","data":{"transactionReference":"a1632e4ea53f2802846cf7797d29bfba","fromUser":"eltnegbiz2","toAccount":"eltnegbiz2","timestamp":1775132847,"fromAmount":1,"toAmount":1000.5,"fromCurrency":"USD","toCurrency":"NGN"},"signature":"ISPOtoKOJRSuT0rKoz0P7JSPSna6UztGOrkKJKWSn62SqUobtaCYURVbhE6A8eIt82qJ9+32XlK1GJZkA5A64MYuDBLF9MdlHvfuNDkPRTof4RV+8UdlSifmBqAU2oYtvA6z4aFHUdiRcfpPNSdkRmLARwIbBxv9Zg6GHFV5HKqybMNt0QaCQskUWVcWOnCEf9PpKV1cKhJWWchYNpiCJgcV8N/vUgOFFVymhkXzbfZADuLukEwScJ751Ll97wWpLwZ1s+1DoBNQ5cY32ft80c3fwnO1NpAW/n22Bpf82rdLP1JctaJUV/3yuorMh+qNaKaXocAMpn6Vkxu8NdyNdQ=="}`;
const PUBLIC_KEY = `-----BEGIN RSA PUBLIC KEY-----
MIIBCgKCAQEAv2ipgHLFFHgGHr9VpsPN8V1HIbCrlmTZRU/CYSDaoVX+xerJOMGX
qmwgQMQH5T81VaMw4rtIA8tT4DkJgjb+7G0x4CGK1OPdlvhEGP2mOFy02onkEnMv
uN3glVc4YKLvWDTG0KT7q9mARBIkO2Nrwy6IVHAl9pMXMJTRS22c0cIbuRmkYsGZ
trylUv50knbRSgy5EA6523+j3PPJB4TgsigGSJxJGuksaxnDQGRE558xnyw/0gJm
mAIdbxboQTGMqod/My/kAssRkUNu1QtqrsdhZmGYHS+pIPJSaxqHEy8eiTahoqqq
8KgNUfQfwduG+Kc4f/t5JHetSt1dgulmswIDAQAB
-----END RSA PUBLIC KEY-----`;
function verifySignature(data: string, signature: string) {
return crypto.verify(
"sha256",
Buffer.from(JSON.stringify(data), "utf8"),
{
key: PUBLIC_KEY,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
},
Buffer.from(signature, "base64"),
);
}
function run() {
const { event, data, signature } = JSON.parse(SAMPLE_WEBHOOK_REQUEST);
const isValid = verifySignature(data, signature);
if (isValid) {
console.log("Signature is valid. Processing event:", event);
// Process the event data as needed
} else {
console.error("Invalid signature. Possible tampering detected.");
}
}
run();
# install dependencies with `pip install cryptography`
import base64
import json
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
SAMPLE_WEBHOOK_REQUEST = b'{"event":"Convert","data":{"transactionReference":"a1632e4ea53f2802846cf7797d29bfba","fromUser":"eltnegbiz2","toAccount":"eltnegbiz2","timestamp":1775132847,"fromAmount":1,"toAmount":1000.5,"fromCurrency":"USD","toCurrency":"NGN"},"signature":"ISPOtoKOJRSuT0rKoz0P7JSPSna6UztGOrkKJKWSn62SqUobtaCYURVbhE6A8eIt82qJ9+32XlK1GJZkA5A64MYuDBLF9MdlHvfuNDkPRTof4RV+8UdlSifmBqAU2oYtvA6z4aFHUdiRcfpPNSdkRmLARwIbBxv9Zg6GHFV5HKqybMNt0QaCQskUWVcWOnCEf9PpKV1cKhJWWchYNpiCJgcV8N/vUgOFFVymhkXzbfZADuLukEwScJ751Ll97wWpLwZ1s+1DoBNQ5cY32ft80c3fwnO1NpAW/n22Bpf82rdLP1JctaJUV/3yuorMh+qNaKaXocAMpn6Vkxu8NdyNdQ=="}'
PUBLIC_KEY_PEM = b"""-----BEGIN RSA PUBLIC KEY-----
MIIBCgKCAQEAv2ipgHLFFHgGHr9VpsPN8V1HIbCrlmTZRU/CYSDaoVX+xerJOMGX
qmwgQMQH5T81VaMw4rtIA8tT4DkJgjb+7G0x4CGK1OPdlvhEGP2mOFy02onkEnMv
uN3glVc4YKLvWDTG0KT7q9mARBIkO2Nrwy6IVHAl9pMXMJTRS22c0cIbuRmkYsGZ
trylUv50knbRSgy5EA6523+j3PPJB4TgsigGSJxJGuksaxnDQGRE558xnyw/0gJm
mAIdbxboQTGMqod/My/kAssRkUNu1QtqrsdhZmGYHS+pIPJSaxqHEy8eiTahoqqq
8KgNUfQfwduG+Kc4f/t5JHetSt1dgulmswIDAQAB
-----END RSA PUBLIC KEY-----"""
def verify_signature(payload: bytes, signature: str) -> bool:
public_key = serialization.load_pem_public_key(PUBLIC_KEY_PEM)
sig_bytes = base64.b64decode(signature)
try:
public_key.verify(
sig_bytes,
payload,
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH
),
hashes.SHA256(),
)
return True
except Exception:
return False
def main():
json_data = json.loads(SAMPLE_WEBHOOK_REQUEST)
event = json_data["event"]
signature = json_data["signature"]
# extract data bytes
payload = SAMPLE_WEBHOOK_REQUEST[
SAMPLE_WEBHOOK_REQUEST.index(b'"data":') + 7
:
SAMPLE_WEBHOOK_REQUEST.index(b"}") + 1
]
if verify_signature(payload, signature):
print(f"Signature valid for event: {event}")
# Process the event data as needed
else:
print("Invalid signature")
if __name__ == "__main__":
main()
package main
import (
"crypto"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
)
var (
SAMPLE_WEBHOOK_REQUEST = `{"event":"Convert","data":{"transactionReference":"a1632e4ea53f2802846cf7797d29bfba","fromUser":"eltnegbiz2","toAccount":"eltnegbiz2","timestamp":1775132847,"fromAmount":1,"toAmount":1000.5,"fromCurrency":"USD","toCurrency":"NGN"},"signature":"ISPOtoKOJRSuT0rKoz0P7JSPSna6UztGOrkKJKWSn62SqUobtaCYURVbhE6A8eIt82qJ9+32XlK1GJZkA5A64MYuDBLF9MdlHvfuNDkPRTof4RV+8UdlSifmBqAU2oYtvA6z4aFHUdiRcfpPNSdkRmLARwIbBxv9Zg6GHFV5HKqybMNt0QaCQskUWVcWOnCEf9PpKV1cKhJWWchYNpiCJgcV8N/vUgOFFVymhkXzbfZADuLukEwScJ751Ll97wWpLwZ1s+1DoBNQ5cY32ft80c3fwnO1NpAW/n22Bpf82rdLP1JctaJUV/3yuorMh+qNaKaXocAMpn6Vkxu8NdyNdQ=="}`
PUBLIC_KEY_PEM = `-----BEGIN RSA PUBLIC KEY-----
MIIBCgKCAQEAv2ipgHLFFHgGHr9VpsPN8V1HIbCrlmTZRU/CYSDaoVX+xerJOMGX
qmwgQMQH5T81VaMw4rtIA8tT4DkJgjb+7G0x4CGK1OPdlvhEGP2mOFy02onkEnMv
uN3glVc4YKLvWDTG0KT7q9mARBIkO2Nrwy6IVHAl9pMXMJTRS22c0cIbuRmkYsGZ
trylUv50knbRSgy5EA6523+j3PPJB4TgsigGSJxJGuksaxnDQGRE558xnyw/0gJm
mAIdbxboQTGMqod/My/kAssRkUNu1QtqrsdhZmGYHS+pIPJSaxqHEy8eiTahoqqq
8KgNUfQfwduG+Kc4f/t5JHetSt1dgulmswIDAQAB
-----END RSA PUBLIC KEY-----`
)
func VerifyData(data []byte, signature string) bool {
sig, err := base64.StdEncoding.DecodeString(signature)
if err != nil {
fmt.Println(err)
return false
}
block, _ := pem.Decode([]byte(PUBLIC_KEY_PEM))
if block == nil {
fmt.Println("failed to parse PEM block containing the public key")
return false
}
pubkey, _ := x509.ParsePKCS1PublicKey(block.Bytes)
msgHash := sha256.New()
msgHash.Write(data)
msgHashSum := msgHash.Sum(nil)
err = rsa.VerifyPSS(pubkey, crypto.SHA256, msgHashSum, sig, nil)
return err == nil
}
func main() {
var data struct {
Event string `json:"event"`
Data json.RawMessage `json:"data"`
Signature string `json:"signature"`
}
json.Unmarshal([]byte(SAMPLE_WEBHOOK_REQUEST), &data)
isValid := VerifyData(data.Data, data.Signature)
if isValid {
fmt.Println("Signature is valid")
} else {
fmt.Println("Signature is invalid")
}
}
Public keys
Use the correct public key for your environment.Staging
-----BEGIN RSA PUBLIC KEY-----
MIIBCgKCAQEAv2ipgHLFFHgGHr9VpsPN8V1HIbCrlmTZRU/CYSDaoVX+xerJOMGX
qmwgQMQH5T81VaMw4rtIA8tT4DkJgjb+7G0x4CGK1OPdlvhEGP2mOFy02onkEnMv
uN3glVc4YKLvWDTG0KT7q9mARBIkO2Nrwy6IVHAl9pMXMJTRS22c0cIbuRmkYsGZ
trylUv50knbRSgy5EA6523+j3PPJB4TgsigGSJxJGuksaxnDQGRE558xnyw/0gJm
mAIdbxboQTGMqod/My/kAssRkUNu1QtqrsdhZmGYHS+pIPJSaxqHEy8eiTahoqqq
8KgNUfQfwduG+Kc4f/t5JHetSt1dgulmswIDAQAB
-----END RSA PUBLIC KEY-----
Production
-----BEGIN RSA PUBLIC KEY-----
MIIBCgKCAQEAoGmOqVUxIGq92P8J5KqwJbwucsNeUwfS76saj0UPgT1IsK/mvceR
Ges7dsgE4EVx7Wsd5PPNeAfaNt8d0plBLhHRW64WFyv6jYcYp8eVdHUxWLA6p5gZ
9rGrZiwqKqunppTPlV04gdDC32rAbpAR3IMYmMJLuPy63Oumszl4qk69A1o60Son
r0KYBaRK7aQsFT9IFexicDUhrF1SohaNH/msTdvJb0SwSGiV92EhmmC2R8CL83/B
S8QC5c9PWtZ4a26CRLHe0IfaGGz8ClhlO8IFxz0cNrpoRa4JRsffezQ+RMQqCxhc
igC7wHfqZr5BtziQOYJjUknVzsd81HEYlwIDAQAB
-----END RSA PUBLIC KEY-----
Best practices
Always verify signatures. Never process a webhook event without verifying the signature first. This prevents spoofed events from affecting your system. Return 200 immediately. Acknowledge receipt before processing. If your endpoint takes too long to respond, the delivery may be retried. Handle duplicates. Use thesessionId or rampReference in the event data as an idempotency key. The same event may be delivered more than once.
Log raw payloads. Store the raw webhook body for debugging. If something goes wrong, the raw payload is essential for troubleshooting.