php javascript java ruby python go csharp cURL

Webhooks

Wave uses webhooks to notify your application when an event occurs in your account. Webhooks are particularly useful for asynchronous events like when a checkout is completed, a checkout payment fails, or a merchant payment is received.

To create a webhook, you must specify a URL to which Wave will send the event data and also specify the types of events you want to receive on the given url.

Working with Wave webhooks

  1. Create one or more webhook endpoints on your server to receive events from Wave.
  2. Register the URLs of your webhook endpoints and choose which events you want to be notified about for each.
  3. When an event occurs, Wave sends a POST request to the URLs that have been registered for the event. The body of the request is an Event object
  4. Upon receiving an event notification, your webhook must verify that the event was sent by Wave by following one of our available security strategies.
  5. Respond to the event with a successful status code (HTTP 2xx) before performing any business logic.
  6. Perform business logic in response to the event.

Duplicate Webhooks

Webhook registration

You can manage webhooks in the Business Portal, in the developers section . Here you can also test the health of your endpoints, and get feedback on error rates.

Webhook registration

To register a webhook, you need to provide the URL of your server's endpoint, the security strategy and check the types of events you want to be notified about on that URL.

Webhook registration detail

After the webhook is registered you will be shown the secret associated with it. You must keep this secret secured and ensure it's not saved in plaintext anywhere nor leaked in logs.

Webhook registration secret

Webhook security

To ensure that an event notification is from Wave webhooks are associated with a secret that is used in the header of each request as a signature or bearer token, depending on the strategy selected.

Shared Secret

This strategy is the simplest way to verify the origin of the request. The webhook secret will be included on the Authorization header, requiring you to compare it with the one saved on your system. If they match, then you can be assured the request is coming from Wave, and proceed with computing the payment.

Example of webhook with Shared Secret strategy

Webhook secret

wave_sn_WHS_xz4m6g8rjs9bshxy05xj4khcvjv7j3hcp4fbpvv6met0zdrjvezg

Authorization header of the webhook's POST request to your server

Bearer wave_sn_WHS_xz4m6g8rjs9bshxy05xj4khcvjv7j3hcp4fbpvv6met0zdrjvezg

Signing Secret

The signing-secret strategy utilizes the webhook secret to sign the hashed body of the request, including it on the Wave-Signature header. This strategy allows you to verify not only the origin of the request, but also the integrity of the body. We recommend this strategy if you require extra security.

Here's an example of the Wave-Signature header. Newlines have been added for readability. The actual header would appear on one line:

Wave-Signature:
t=1639081943,
v1=942119aedf9fa377844cf010785fe14ef8478c72af0b73d62ea3941335b526a8,
v1=f0219658e485a20af77bee4ecfec77a900ee14380f9f4894ede11e33c465c32e

The Wave-Signature header consists of a timestamp and one or more signatures. The elements of the header are separated by commas. The timestamp is prefixed by t= and each signature is prefixed by v1. The timestamp itself is expressed in Unix time.

The header contains a signature for each secret on your webhook. Usually you would only have one active secret, but for a time after you change secrets, you may have more than one that is active. The signature is a hash-based message authentication code (HMAC) generated from the payload using a SHA256 hash function.

To verify the webhook signature, you compute the expected HMAC value based on the body of the event message and the webhook secret and make sure that the result is provided as one of the signatures in the header. To prevent replay attacks you may reject messages where the timestamp is too old (five minutes is a reasonable interval).

The steps to verify the Wave-Signature header are:

Signature verification implementation:


<?php
function webhook_verify_signature_and_decode($wave_webhook_secret, $wave_signature, $webhook_body) {
    // Uncomment var_dump if you need to debug the 3 input values:
    // var_dump($wave_webhook_secret, $wave_signature, $webhook_body);

    $parts = explode(",", $wave_signature);
    $timestamp = explode("=", $parts[0])[1];

    $signatures = array();
    foreach (array_slice($parts, 1) as $signature) {
        $signatures[] = explode("=", $signature)[1];
    }

    $computed_hmac = hash_hmac("sha256", $timestamp . $webhook_body, $wave_webhook_secret);
    $valid = in_array($computed_hmac, $signatures);

    if($valid) {
        # The request is valid, you can proceed using the decoded contents.
        return json_decode($webhook_body);
    } else {
        die("Invalid webhook signature.");
    }
}

// This key is given to you by a Wave Representative when we register
// your webhook URL. (This is different from the API key)
$wave_webhook_secret = "wave_sn_WHS_xz4m6g8rjs9bshxy05xj4khcvjv7j3hcp4fbpvv6met0zdrjvezg";

// # This header is sent by Wave in each webhook POST request.
// (The plain HTTP header is 'Wave-Signature')
$wave_signature = $_SERVER['HTTP_WAVE_SIGNATURE'];

// The body of the request, as a plain string, not yet parsed to JSON:
$webhook_body = file_get_contents('php://input');

$webhook_json = webhook_verify_signature_and_decode($wave_webhook_secret, $wave_signature, $webhook_body)

// You can now proceed to use the webhook data to process the request:
$webhook_type = $webhook_json->type;
$webhook_data = $webhook_json->data;
?>
const crypto = require('crypto');

const validateWaveSignature = (waveSignature, rawBody, webhookSecret) => {
  const parts = waveSignature.split(',');

  const timestampPart = parts.find(comp => comp.startsWith('t='));
  const timestamp = timestampPart?.split("=")[1];

  const signatureParts = parts.filter(comp => comp.startsWith('v1='));
  const signatures = signatureParts.map(s => {return s.split("=")[1]})

  const payload = timestamp + rawBody;

  const calculatedSignature = crypto.createHmac('sha256', webhookSecret).update(payload).digest("hex");

  return signatures.includes(calculatedSignature);
};
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.util.ArrayList;
import java.util.List;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public class WaveWebhookSignatureVerifier {
    private static final byte[] HEX_ARRAY = "0123456789abcdef".getBytes(StandardCharsets.US_ASCII);

    public static boolean verifySignature(String waveSignature, String body, String webhookSecret) throws GeneralSecurityException {
        final String[] waveSignatureParts = waveSignature.split(",");
        String timestamp = "";
        List<String> signatures = new ArrayList<>();

        for (String elem : waveSignatureParts) {
            String[] keyval = elem.split("=");
            String key = keyval[0];
            String val = keyval[1];
            // Uncomment this if you want to see which values were extracted:
            // System.out.printf("key: %s, val: %s %n", key, val);
            if (key.equals("t")) {
                timestamp = val;
            } else {
                signatures.add(val);
            }
        }

        String data = timestamp + body;

        Mac sha256_HMAC = Mac.getInstance("HmacSHA256");
        SecretKeySpec secret_key = new SecretKeySpec(webhookSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
        sha256_HMAC.init(secret_key);
        byte[] hash = sha256_HMAC.doFinal(data.getBytes(StandardCharsets.UTF_8));


        String calculatedSignature = bytesToHex(hash);
        // Uncomment this if you want to see what was calculated:
        // System.out.printf("Signature: %s %n", calculatedSignature);

        return signatures.contains(calculatedSignature);
    }


    private static String bytesToHex(byte[] bytes) {
        byte[] hexChars = new byte[bytes.length * 2];
        for (int j = 0; j < bytes.length; j++) {
            int v = bytes[j] & 0xFF;
            hexChars[j * 2] = HEX_ARRAY[v >>> 4];
            hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F];
        }
        return new String(hexChars, StandardCharsets.UTF_8);
    }
}
require 'openssl'

def validate_wave_signature(wave_signature, raw_body, webhook_secret)
  parts = wave_signature.split(',')
  timestamp = parts.find { |comp| comp.start_with?('t=') }&.split('=')&.last
  signatures = parts.select { |comp| comp.start_with?('v1=') }.map { |s| s.split('=').last }

  payload = timestamp + raw_body

  calculated_signature = OpenSSL::HMAC.hexdigest('sha256', webhook_secret, payload)

  signatures.include?(calculated_signature)
end
import hmac
import hashlib

def validate_wave_signature(wave_signature, raw_body, webhook_secret):
    parts = wave_signature.split(',')
    timestamp = parts[0].split('=')[1]
    signatures = [s.split('=')[1] for s in parts[1:]]

    payload = timestamp + raw_body

    calculated_signature = hmac.new(webhook_secret.encode('utf-8'), payload.encode('utf-8'), hashlib.sha256).hexdigest()

    return calculated_signature in signatures
package main

import (
  "crypto/hmac"
  "crypto/sha256"
  "encoding/hex"
  "fmt"
  "strings"
)

func validateWaveSignature(waveSignature, rawBody, webhookSecret string) bool {
  parts := strings.Split(waveSignature, ",")
  timestamp := strings.Split(parts[0], "=")[1]
  signatures := []string{}
  for _, part := range parts[1:] {
    signatures = append(signatures, strings.Split(part, "=")[1])
  }

  payload := timestamp + rawBody

  h := hmac.New(sha256.New, []byte(webhookSecret))
  h.Write([]byte(payload))
  calculatedSignature := hex.EncodeToString(h.Sum(nil))

  for _, signature := range signatures {
    if signature == calculatedSignature {
      return true
    }
  }
  return false
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;

public class WaveSignatureValidator
{
    public static bool ValidateSignature(string waveSignature, string body, string webhookSecret)
    {
        var parts = waveSignature.Split(",");
        var timestamp = parts.First(p => p.StartsWith("t=")).Split("=")[1];
        var signatures = parts.Where(p => p.StartsWith("v1=")).Select(p => p.Split("=")[1]).ToList();

        var payload = timestamp + body;

        using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(webhookSecret)))
        {
            var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
            var calculatedSignature = BitConverter.ToString(hash).Replace("-", "").ToLower();

            return signatures.Contains(calculatedSignature);
        }
    }
}

Help, the signature validation fails

The most common reason for this is that some frameworks automatically parse the body of the webhook you receive. This gives the wrong result when you calculate the hash. The reason for this is that the signature is only unique if the key order in the JSON hasn't changed, there are no additional or missing spaces, and there are no additional line breaks.

The solution is to not parse the body, but instead do the signature validation against the raw string you received.

Here are some common examples that indicate this problem.

Example Wave-Signature validation

Webhook secret

'wave_sn_WHS_xz4m6g8rjs9bshxy05xj4khcvjv7j3hcp4fbpvv6met0zdrjvezg'

Wave-Signature header of the webhook's POST request to your server

't=1667920421,v1=53c971695230e9c51b1030d673eee76e70bbcdf8a7c5b8c1d44e0b8b1329647b'

Webhook body of the webhook's POST request to your server

'{"id": "AE_ijzo7oGgrlM7", "type": "checkout.session.completed", "data": {"id": "cos-1b01sghpg100j", "amount": "100", "checkout_status": "complete", "client_reference": null, "currency": "XOF", "error_url": "https://example.com/error", "last_payment_error": null, "business_name": "Annas Apiaries", "payment_status": "succeeded", "success_url": "https://example.com/success", "wave_launch_url": "https://pay.wave.com/c/cos-1b01sghpg100j?a=100&c=XOF&m=Annas%20Apiaries", "when_completed": "2022-11-08T15:05:45Z", "when_created": "2022-11-08T15:05:32Z", "when_expires": "2022-11-09T15:05:32Z", "transaction_id": "TCN4Y4ZC3FM"}}'

The following is a full example for webhook signature validation. The 3 things you need are

Note that 3 examples are quoted with ', since they are strings, but the outer quotes aren't part of the string.

Follow the instructions under Wave-Signature validation to verify the signature in the example.

Example 1

This is a correct payload:

'{"id": "AE_ijzo7oGgrlM7", "type": "checkout.session.completed", "data": {"id": "cos-1b01sghpg100j", "amount": "100", "checkout_status": "complete", "client_reference": null, "currency": "XOF", "error_url": "https://example.com/error", "last_payment_error": null, "business_name": "Annas Apiaries", "payment_status": "succeeded", "success_url": "https://example.com/success", "wave_launch_url": "https://pay.wave.com/c/cos-1b01sghpg100j?a=100&c=XOF&m=Annas%20Apiaries", "when_completed": "2022-11-08T15:05:45Z", "when_created": "2022-11-08T15:05:32Z", "when_expires": "2022-11-09T15:05:32Z", "transaction_id": "TCN4Y4ZC3FM"}}'

Example 2

The missing spaces indicate that the body was parsed as JSON and then converted into string again:

'{"id":"AE_ijzo7oGgrlM7","type":"checkout.session.completed","data":{"id":"cos-1b01sghpg100j","amount":"100","checkout_status":"complete","client_reference":null,"currency":"XOF","error_url":"https://example.com/error","last_payment_error":null,"business_name":"AnnasApiaries","payment_status":"succeeded","success_url":"https://example.com/success","wave_launch_url":"https://pay.wave.com/c/cos-1b01sghpg100j?a=100&c=XOF&m=Annas%20Apiaries","when_completed":"2022-11-08T15:05:45Z","when_created":"2022-11-08T15:05:32Z","when_expires":"2022-11-09T15:05:32Z","transaction_id":"TCN4Y4ZC3FM"}}'

Example 3

You only hash the data payload, but you need the full body:

{"id": "cos-1b01sghpg100j", "amount": "100", "checkout_status": "complete", "client_reference": null, "currency": "XOF", "error_url": "https://example.com/error", "last_payment_error": null, "business_name": "Annas Apiaries", "payment_status": "succeeded", "success_url": "https://example.com/success", "wave_launch_url": "https://pay.wave.com/c/cos-1b01sghpg100j?a=100&c=XOF&m=Annas%20Apiaries", "when_completed": "2022-11-08T15:05:45Z", "when_created": "2022-11-08T15:05:32Z", "when_expires": "2022-11-09T15:05:32Z", "transaction_id": "TCN4Y4ZC3FM"}

Example 4

We don't send line breaks in the body, so the JSON below is not the exact body string content:

{
 "id": "AE_ijzo7oGgrlM7",
 "type": "checkout.session.completed",
 "data": {
    "id": "cos-1b01sghpg100j",
    "amount": "100",
    "checkout_status": "complete",
    "client_reference": null,
    "currency": "XOF",
    "error_url": "https://example.com/error",
    "last_payment_error": null,
    "business_name": "Annas Apiaries",
    "payment_status": "succeeded",
    "success_url": "https://example.com/success",
    "wave_launch_url": "https://pay.wave.com/c/cos-1b01sghpg100j?a=100&c=XOF&m=Annas%20Apiaries",
    "when_completed": "2022-11-08T15:05:45Z",
    "when_created": "2022-11-08T15:05:32Z",
    "when_expires": "2022-11-09T15:05:32Z",
    "transaction_id": "TCN4Y4ZC3FM"
 }
}

IP Whitelisting

For enhanced security, we require whitelisting of all our public IP addresses.

Secret Rotation

It is a security best practice to occasionally rotate webhook secrets. In situations where your webhook secret has been exposed, you should always immediately rotate the secret. Wave may also prompt you to rotate the secrets if the webhook is too old, as shown in the following screenshot.

Webhook needs rotation

Rotating a secret requires recreating a webhook to obtain a new secret and then removing the old webhook. The simplest way to do this is using the duplicate option from the webhook list.

Webhook duplicate Webhook duplicate confirm

After confirming the duplication, you will be shown the secret of this new webhook. In the list of webhooks you should now see two webhooks with the same URL, secret strategy and event subscription. Their only difference will be the creation date.

Webhook duplicate

While these two webhooks are configured at the same time, your system will receive two notifications per event, one secured with the old secret and one with the new one.

Until you exchange these secrets in your system, the notifications related to the new webhook will be rejected by the validation logic. After changing the old secret for the new one, then the notifications related to the old secret will start being rejected by the validation, while the new ones will be now accepted. After changing the secret and ensuring you are accepting requests with the new webhook secret, you can remove the old secret by using the delete option from the webhook list.

Webhook delete Webhook delete

Step by step secret rotation

  1. Log into the business portal
  2. Go to Developers -> Webhooks
  3. On the list of webhooks, identify the one you want to rotate can select the option Duplicate from the three dots menu on the right
  4. Confirm the webhook duplication
    • From this point on, your system will receive two webhook notifications per event
  5. Copy the new webhook secret
  6. On your system, exchange the old webhook secret used for validation with the new one
  7. Send a test event from the new webhook and confirm on your server that you can receive and validate its header correctly
  8. Delete the old webhook by selecting the option Delete from the three dots menu on the right
    • Rotation is completed, your system will receive again only one webhook notification per event

Webhook requests

When you've registered a webhook to receive notifications of some events and one of those events occurs, Wave sends a request to the webhook endpoint. The body of the request is an Event object. It contains an event type which describes the event and an object which describes the changed state.

As an example, when a checkout session is completed successfully, the event type would be checkout.session.completed and the data object would be a Checkout Session object containing the details of the checkout with a status of complete.

Event Types

In the list below of supported events, the event type is listed in bold followed by the name of the associated data object in italics.

checkout.session.completed Checkout Session
Occurs when a checkout session is completed.

checkout.session.payment_failed Checkout Session
Occurs when a payment for a checkout session fails. Note that you may receive several failures before a success occurs.

b2b.payment_received B2B Payment
Occurs when a B2B payment is received.

b2b.payment_failed B2B Payment
Occurs when a B2B payment failure occurred. Note that you may receive several failures before a success occurs.

merchant.payment_received
Occurs when a merchant payment is received.

Events

Wave webhooks use events to notify you about changes related to your account. When a notification is sent to your webhook, the body of the request consists of an Event object. The data property of the Event object depends on the type of event that occurred. For example, if the event type is merchant.payment.received the data property will contain a Merchant Payment object.

The Event object

The Event object provides information about a change that occurred on your account and information about the state of the change.

Attributes


Attribute Type Description
id string Unique identifier for the Event object.
data hash Object containing data related to the event.
type string Name of the event.

Event object examples

The following are examples of Event objects for different event types.

FOR CHECKOUT SESSION COMPLETED

  {
    "id": "EV_QvEZuDSQbLdI",
    "type": "checkout.session.completed",
    "data": {
      "id": "cos-18qq25rgr100a",
      "amount": "1000",
      "checkout_status": "complete",
      "client_reference": "1f31dfd7-aec8-4adf-84ff-4a9c1981be2a",
      "currency": "XOF",
      "error_url": "https://example.com/error",
      "payment_status": "succeeded",
      "success_url": "https://example.com/success",
      "wave_launch_url": "https://pay.wave.com/c/cos-18qq25rgr100a",
      "when_completed": "2021-12-08T10:15:32Z",
      "when_created": "2021-12-08T10:13:04Z",
      "when_expires": "2021-12-09T10:13:04Z"
    }
  }

FOR MERCHANT PAYMENT RECEIVED WITH CUSTOM FIELDS

  {
    "id": "AE_ijzo7oGgrlM8",
    "type": "merchant.payment_received",
    "data": {
    "id": "T_46HS5COOWE",
    "amount": "1000",
    "currency": "XOF",
    "sender_mobile": "+221761110001",
    "custom_fields": {
      "account_number": "abc-123"
    },
    "when_created": "2021-12-08T10:13:04Z"
   }
  }

FOR B2B PAYMENT RECEIVED

 {
    "id": "AE_ijzo7oGgrlM8",
    "type": "b2b.payment_received",
    "data": {
      "amount": "39800",
      "client_reference": "1f31dfd7-aec8-4adf-84ff-4a9c1981be2a",
      "currency": "XOF",
      "id": "b2b-1ndjb8dj81008",
      "sender_id": "M_qn0zhfcKV1Tl",
      "when_created": "2022-08-10T14:28:15.585392"
    }
  }

FOR B2B PAYMENT FAILED

  {
    "id": "AE_8bO0d7TwW6Eq",
    "type": "b2b.payment_failed",
    "data": {
      "amount": "39800",
      "client_reference": "1f31dfd7-aec8-4adf-84ff-4a9c1981be2a",
      "currency": "XOF",
      "id": "b2b-1ndj717m0100e",
      "last_payment_error": {
        "code": "insufficient-funds",
        "message": "Insufficient balance. Please visit a Wave agent to deposit."
      },
      "sender_id": "M_yk7rFUnaA9n8",
      "when_created": "2022-08-10T14:28:15.987217"
    }
}

FOR CHECKOUT SESSION PAYMENT FAILED

  {
    "id": "EV_QvEZuDSQbLdI",
    "type": "checkout.session.payment_failed",
    "data": {
      "id": "cos-18qq25rgr100a",
      "amount": "1000",
      "checkout_status": "failed",
      "client_reference": "1f31dfd7-aec8-4adf-84ff-4a9c1981be2a",
      "currency": "XOF",
      "error_url": "https://example.com/error",
      "payment_status": "failed",
      "success_url": "https://example.com/success",
      "wave_launch_url": "https://pay.wave.com/c/cos-18qq25rgr100a",
      "when_created": "2021-12-08T10:13:04Z",
      "when_expires": "2021-12-09T10:13:04Z"
    }
  }

FOR MERCHANT PAYMENT RECEIVED

{
    "id": "AE_ijzo7oGgrlM8",
    "type": "merchant.payment_received",
    "data": {
      "id": "T_46HS5COOWE",
      "amount": "1000",
      "currency": "XOF",
      "sender_mobile": "+221761110001",
      "when_created": "2021-12-08T10:13:04Z"
   }
 }

Testing your webhook

You can test your webhook by sending a test event to your webhook endpoint. This is useful for verifying that your webhook endpoint is working correctly and that it can handle the events that you want to receive.

Using the Business Portal

You can also use the Webhook Tester in the Business Portal. Here you can click on your registered webhook endpoint and send a test event to see the response.

Webhook Tester

Manual Testing

You can use the example requests to make sure that your server can reliably receive and process webhooks.

Replace <your webhook URL> with your server's address, and test if you receive the request and the parse the data in the body.

Make sure to test the following:

Once your test server can process these successfully, you are ready to switch on signature validation. This will make sure only webhooks originating from Wave can trigger events in your system.

Basic Example

Basic webhook test example:

curl -i -X POST \
  --url <your webhook URL> \
  -d '{"test_key": "test_value"}'
<?php
$ch = curl_init();

curl_setopt($ch, CURLOPT_URL, '<your webhook URL>');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_POST, 1);

$data = array(
  'test_key' => 'test_value'
);

curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));

$headers = array();
$headers[] = 'Content-Type: application/json';
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

$result = curl_exec($ch);
if (curl_errno($ch)) {
  echo 'Error:' . curl_error($ch);
}
curl_close($ch);
?>
var axios = require('axios');
var data = JSON.stringify({"test_key":"test_value"});

var config = {
  method: 'post',
  url: '<your webhook URL>',
  headers: {
    'Content-Type': 'application/json'
  },
  data : data
};

axios(config)
.then(function (response) {
  console.log(JSON.stringify(response.data));
})
.catch(function (error) {
  console.log(error);
});
import java.net.HttpURLConnection;
import java.net.URL;
import java.io.OutputStream;
import java.io.BufferedReader;
import java.io.InputStreamReader;

public class Main {
  public static void main(String[] args) {
    try {
      URL url = new URL("<your webhook URL>");
      HttpURLConnection conn = (HttpURLConnection) url.openConnection();
      conn.setRequestMethod("POST");
      conn.setRequestProperty("Content-Type", "application/json");

      conn.setDoOutput(true);
      OutputStream os = conn.getOutputStream();
      byte[] input = "{\"test_key\": \"test_value\"}".getBytes("utf-8");
      os.write(input, 0, input.length);

      BufferedReader br = new BufferedReader(new InputStreamReader((conn.getInputStream())));

      String output;
      while ((output = br.readLine()) != null) {
        System.out.println(output);
      }

      conn.disconnect();
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
}
require "uri"
require "net/http"

url = URI("<your webhook URL>")
https = Net::HTTP.new(url.host, url.port)
https.use_ssl = true

request = Net::HTTP::Post.new(url)
request["Content-Type"] = "application/json"
request.body = "{\"test_key\":\"test_value\"}"

response = https.request(request)
puts response.read_body
import requests

url = '<your webhook URL>'
payload = {"test_key": "test_value"}
headers = {
  'Content-Type': 'application/json'
}

response = requests.request("POST", url, headers=headers, json = payload)

print(response.text.encode('utf8'))
package main

import (
  "bytes"
  "fmt"
  "net/http"
)

func main() {
  url := "<your webhook URL>"
  method := "POST"

  payload := &bytes.Buffer{}
  payload.WriteString(`{"test_key": "test_value"}`)
  client := &http.Client{}
  req, err := http.NewRequest(method, url, payload)

  if err != nil {
    fmt.Println(err)
    return
  }
  req.Header.Add("Content-Type", "application/json")

  res, err := client.Do(req)
  if err != nil {
    fmt.Println(err)
    return
  }
  defer res.Body.Close()

  fmt.Println(res)
}
using System;
using System.IO;
using System.Net;
using System.Text;

class Program
{
  static void Main()
  {
    var request = (HttpWebRequest)WebRequest.Create("<your webhook URL>");
    request.Method = "POST";
    request.ContentType = "application/json";

    var data = Encoding.ASCII.GetBytes("{\"test_key\":\"test_value\"}");

    using (var stream = request.GetRequestStream())
    {
      stream.Write(data, 0, data.Length);
    }

    var response = (HttpWebResponse)request.GetResponse();

    var responseString = new StreamReader(response.GetResponseStream()).ReadToEnd();
    Console.WriteLine(responseString);
  }
}

Advanced Example

Webhook example with a realistic body:

curl -i -X POST <your webhook URL> \
-H 'Content-Type: application/json' \
-H "Wave-Signature: t=123,v1=abc" \
-d '{
    "id": "EV_QvEZuDSQbLdI",
    "type": "checkout.session.completed",
    "data": {
        "id": "cos-18qq25rgr100a",
        "amount": "100",
        "checkout_status": "complete",
        "client_reference": "cbd2c580",
        "currency": "XOF",
        "payment_status": "succeeded",
        "success_url": "https://example.com/success",
        "error_url": "https://example.com/error",
        "wave_launch_url": "https://pay.wave.com/c/cos-18qq25rgr100a",
        "when_completed": "2022-12-08T10:15:32Z",
        "when_created": "2022-12-08T10:13:04Z",
        "when_expires": "2022-12-09T10:13:04Z"
    }
}'
<?php
$ch = curl_init();

curl_setopt($ch, CURLOPT_URL, '<your webhook URL>');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_POST, 1);

$data = array(
  'id' => 'EV_QvEZuDSQbLdI',
  'type' => 'checkout.session.completed',
  'data' => array(
    'id' => 'cos-18qq25rgr100a',
    'amount' => '100',
    'checkout_status' => 'complete',
    'client_reference' => 'cbd2c580',
    'currency' => 'XOF',
    'payment_status' => 'succeeded',
    'success_url' => 'https://example.com/success',
    'error_url' => 'https://example.com/error',
    'wave_launch_url' => 'https://pay.wave.com/c/cos-18qq25rgr100a',
    'when_completed' => '2022-12-08T10:15:32Z',
    'when_created' => '2022-12-08T10:13:04Z',
    'when_expires' => '2022-12-09T10:13:04Z'
  )
);

curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));

$headers = array();
$headers[] = 'Content-Type: application/json';
$headers[] = 'Wave-Signature: t=123,v1=abc';
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

$result = curl_exec($ch);
if (curl_errno($ch)) {
  echo 'Error:' . curl_error($ch);
}
curl_close($ch);
?>
var axios = require('axios');
var data = JSON.stringify({"id":"EV_QvEZuDSQbLdI","type":"checkout.session.completed","data":{"id":"cos-18qq25rgr100a","amount":"100","checkout_status":"complete","client_reference":"cbd2c580","currency":"XOF","payment_status":"succeeded","success_url":"https://example.com/success","error_url":"https://example.com/error","wave_launch_url":"https://pay.wave.com/c/cos-18qq25rgr100a","when_completed":"2022-12-08T10:15:32Z","when_created":"2022-12-08T10:13:04Z","when_expires":"2022-12-09T10:13:04Z"}});
var config = {
  method: 'post',
  url: '<your webhook URL>',
  headers: {
    'Content-Type': 'application/json',
    'Wave-Signature': 't=123,v1=abc'
  },
  data: data
};
axios(config)
  .then(function (response) {
    console.log(JSON.stringify(response.data));
  })
  .catch(function (error) {
    console.log(error);
  });
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpRequest.BodyPublishers;
import java.net.http.HttpRequest.BodyPublisher;
import java.net.http.HttpResponse.BodyHandlers;
import java.util.Map;
import java.util.HashMap;

public class Main {
  public static void main(String[] args) {
    HttpClient client = HttpClient.newHttpClient();
    HttpRequest request = HttpRequest.newBuilder()
      .uri(URI.create("<your webhook URL>"))
      .header("Content-Type", "application/json")
      .header("Wave-Signature", "t=123,v1=abc")
      .POST(BodyPublishers.ofString("{\"id\":\"EV_QvEZuDSQbLdI\",\"type\":\"checkout.session.completed\",\"data\":{\"id\":\"cos-18qq25rgr100a\",\"amount\":\"100\",\"checkout_status\":\"complete\",\"client_reference\":\"cbd2c580\",\"currency\":\"XOF\",\"payment_status\":\"succeeded\",\"success_url\":\"https://example.com/success\",\"error_url\":\"https://example.com/error\",\"wave_launch_url\":\"https://pay.wave.com/c/cos-18qq25rgr100a\",\"when_completed\":\"2022-12-08T10:15:32Z\",\"when_created\":\"2022-12-08T10:13:04Z\",\"when_expires\":\"2022-12-09T10:13:04Z\"}}"))
      .build();
    try {
      HttpResponse<String> response = client.send(request, BodyHandlers.ofString());
      System.out.println(response.body());
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
}
require 'net/http'
require 'uri'
require 'json'

uri = URI.parse("<your webhook URL>")
request = Net::HTTP::Post.new(uri)
request.content_type = "application/json"
request["Wave-Signature"] = "t=123,v1=abc"
request.body = JSON.dump({
  "id" => "EV_QvEZuDSQbLdI",
  "type" => "checkout.session.completed",
  "data" => {
    "id" => "cos-18qq25rgr100a",
    "amount" => "100",
    "checkout_status" => "complete",
    "client_reference" => "cbd2c580",
    "currency" => "XOF",
    "payment_status" => "succeeded",
    "success_url" => "https://example.com/success",
    "error_url" => "https://example.com/error",
    "wave_launch_url" => "https://pay.wave.com/c/cos-18qq25rgr100a",
    "when_completed" => "2022-12-08T10:15:32Z",
    "when_created" => "2022-12-08T10:13:04Z",
    "when_expires" => "2022-12-09T10:13:04Z"
  }
})

req_options = {
  use_ssl: uri.scheme == "https",
}

response = Net::HTTP.start(uri.hostname, uri.port, req_options) do |http|
  http.request(request)
end
puts response.body
import requests

url = '<your webhook URL>'
payload = {
  "id": "EV_QvEZuDSQbLdI",
  "type": "checkout.session.completed",
  "data": {
    "id": "cos-18qq25rgr100a",
    "amount": "100",
    "checkout_status": "complete",
    "client_reference": "cbd2c580",
    "currency": "XOF",
    "payment_status": "succeeded",
    "success_url": "https://example.com/success",
    "error_url": "https://example.com/error",
    "wave_launch_url": "https://pay.wave.com/c/cos-18qq25rgr100a",
    "when_completed": "2022-12-08T10:15:32Z",
    "when_created": "2022-12-08T10:13:04Z",
    "when_expires": "2022-12-09T10:13:04Z"
  }
}
headers = {
  'Content-Type': 'application/json',
  'Wave-Signature': 't=123,v1=abc'
}

response = requests.request("POST", url, headers=headers, json=payload)

print(response.text)
package main

import (
  "bytes"
  "fmt"
  "net/http"
)

func main() {
  url := "<your webhook URL>"
  method := "POST"

  payload := &bytes.Buffer{}
  payload.WriteString(`{"id": "EV_QvEZuDSQbLdI", "type": "checkout.session.completed", "data": {"id": "cos-18qq25rgr100a", "amount": "100", "checkout_status": "complete", "client_reference": "cbd2c580", "currency": "XOF", "payment_status": "succeeded", "success_url": "https://example.com/success", "error_url": "https://example.com/error", "wave_launch_url": "https://pay.wave.com/c/cos-18qq25rgr100a", "when_completed": "2022-12-08T10:15:32Z", "when_created": "2022-12-08T10:13:04Z", "when_expires": "2022-12-09T10:13:04Z"}}`)

  client := &http.Client{}
  req, err := http.NewRequest(method, url, payload)

  if err != nil {
    fmt.Println(err)
    return
  }
  req.Header.Add("Content-Type", "application/json")
  req.Header.Add("Wave-Signature", "t=123,v1=abc")

  res, err := client.Do(req)
  if err != nil {
    fmt.Println(err)
    return
  }
  defer res.Body.Close()

  fmt.Println("response Status:", res.Status)
}
using System;
using System.IO;
using System.Net;
using System.Text;

class Program
{
  static void Main()
  {
    var request = (HttpWebRequest)WebRequest.Create("<your webhook URL>");
    request.Method = "POST";
    request.ContentType = "application/json";
    request.Headers.Add("Wave-Signature", "t=123,v1=abc");

    var data = Encoding.ASCII.GetBytes("{\"id\":\"EV_QvEZuDSQbLdI\",\"type\":\"checkout.session.completed\",\"data\":{\"id\":\"cos-18qq25rgr100a\",\"amount\":\"100\",\"checkout_status\":\"complete\",\"client_reference\":\"cbd2c580\",\"currency\":\"XOF\",\"payment_status\":\"succeeded\",\"success_url\":\"https://example.com/success\",\"error_url\":\"https://example.com/error\",\"wave_launch_url\":\"https://pay.wave.com/c/cos-18qq25rgr100a\",\"when_completed\":\"2022-12-08T10:15:32Z\",\"when_created\":\"2022-12-08T10:13:04Z\",\"when_expires\":\"2022-12-09T10:13:04Z\"}}");
    request.ContentLength = data.Length;

    using (var stream = request.GetRequestStream())
    {
      stream.Write(data, 0, data.Length);
    }

    var response = (HttpWebResponse)request.GetResponse();

    var responseString = new StreamReader(response.GetResponseStream()).ReadToEnd();
    Console.WriteLine(responseString);
  }
}

Full Webhook Example

Full webhook example, including a valid signature. The signature needs to be validated against the webhook secret wave_sn_WHS_xz4m6g8rjs9bshxy05xj4khcvjv7j3hcp4fbpvv6met0zdrjvezg:

curl -i -X POST <your webhook URL> \
-H 'Content-Type: application/json' \
-H "Wave-Signature: t=1667920421,v1=53c971695230e9c51b1030d673eee76e70bbcdf8a7c5b8c1d44e0b8b1329647b" \
-d '{
    "id": "AE_ijzo7oGgrlM7",
     "type": "checkout.session.completed",
      "data": {
        "id": "cos-1b01sghpg100j",
        "amount": "100",
        "checkout_status": "complete",
        "client_reference": null,
        "currency": "XOF",
        "error_url": "https://example.com/error",
        "last_payment_error": null,
        "business_name": "Annas Apiaries",
        "payment_status": "succeeded",
        "success_url": "https://example.com/success",
        "wave_launch_url": "https://pay.wave.com/c/cos-1b01sghpg100j?a=100&c=XOF&m=Annas%20Apiaries",
        "when_completed": "2022-11-08T15:05:45Z",
        "when_created": "2022-11-08T15:05:32Z",
        "when_expires": "2022-11-09T15:05:32Z",
        "transaction_id": "TCN4Y4ZC3FM"
      }
    }'
<?php
$ch = curl_init();

curl_setopt($ch, CURLOPT_URL, '<your webhook URL>');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_POST, 1);

$data = array(
  'id' => 'AE_ijzo7oGgrlM7',
  'type' => 'checkout.session.completed',
  'data' => array(
    'id' => 'cos-1b01sghpg100j',
    'amount' => '100',
    'checkout_status' => 'complete',
    'client_reference' => null,
    'currency' => 'XOF',
    'error_url' => 'https://example.com/error',
    'last_payment_error' => null,
    'business_name' => 'Annas Apiaries',
    'payment_status' => 'succeeded',
    'success_url' => 'https://example.com/success',
    'wave_launch_url' => 'https://pay.wave.com/c/cos-1b01sghpg100j?a=100&c=XOF&m=Annas%20Apiaries',
    'when_completed' => '2022-11-08T15:05:45Z',
    'when_created' => '2022-11-08T15:05:32Z',
    'when_expires' => '2022-11-09T15:05:32Z',
    'transaction_id' => 'TCN4Y4ZC3FM'
  )
);

curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));

$headers = array();
$headers[] = 'Content-Type: application/json';
$headers[] = 'Wave-Signature: t=1667920421,v1=53c971695230e9c51b1030d673eee76e70bbcdf8a7c5b8c1d44e0b8b1329647b';
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

$result = curl_exec($ch);
if (curl_errno($ch)) {
  echo 'Error:' . curl_error($ch);
}
curl_close($ch);
?>
var axios = require('axios');
var data = JSON.stringify({"id":"AE_ijzo7oGgrlM7","type":"checkout.session.completed","data":{"id":"cos-1b01sghpg100j","amount":"100","checkout_status":"complete","client_reference":null,"currency":"XOF","error_url":"https://example.com/error","last_payment_error":null,"business_name":"Annas Apiaries","payment_status":"succeeded","success_url":"https://example.com/success","wave_launch_url":"https://pay.wave.com/c/cos-1b01sghpg100j?a=100&c=XOF&m=Annas%20Apiaries","when_completed":"2022-11-08T15:05:45Z","when_created":"2022-11-08T15:05:32Z","when_expires":"2022-11-09T15:05:32Z","transaction_id":"TCN4Y4ZC3FM"}});

var config = {
  method: 'post',
  url: '<your webhook URL>',
  headers: {
    'Content-Type': 'application/json',
    'Wave-Signature': 't=1667920421,v1=53c971695230e9c51b1030d673eee76e70bbcdf8a7c5b8c1d44e0b8b1329647b'
  },
  data : data
};

axios(config)
.then(function (response) {
  console.log(JSON.stringify(response.data));
})
.catch(function (error) {
  console.log(error);
});
import java.net.HttpURLConnection;
import java.net.URL;
import java.io.OutputStream;
import java.io.BufferedReader;
import java.io.InputStreamReader;

public class Main {
  public static void main(String[] args) {
    try {
      URL url = new URL("<your webhook URL>");
      HttpURLConnection conn = (HttpURLConnection) url.openConnection();
      conn.setRequestMethod("POST");
      conn.setRequestProperty("Content-Type", "application/json");
      conn.setRequestProperty("Wave-Signature", "t=1667920421,v1=53c971695230e9c51b1030d673eee76e70bbcdf8a7c5b8c1d44e0b8b1329647b");

      conn.setDoOutput(true);
      OutputStream os = conn.getOutputStream();
      byte[] input = "{\"id\": \"AE_ijzo7oGgrlM7\", \"type\": \"checkout.session.completed\", \"data\": {\"id\": \"cos-1b01sghpg100j\", \"amount\": \"100\", \"checkout_status\": \"complete\", \"client_reference\": null, \"currency\": \"XOF\", \"error_url\": \"https://example.com/error\", \"last_payment_error\": null, \"business_name\": \"Annas Apiaries\", \"payment_status\": \"succeeded\", \"success_url\": \"https://example.com/success\", \"wave_launch_url\": \"https://pay.wave.com/c/cos-1b01sghpg100j?a=100&c=XOF&m=Annas%20Apiaries\", \"when_completed\": \"2022-11-08T15:05:45Z\", \"when_created\": \"2022-11-08T15:05:32Z\", \"when_expires\": \"2022-11-09T15:05:32Z\", \"transaction_id\": \"TCN4Y4ZC3FM\"}}".getBytes("utf-8");
      os.write(input, 0, input.length);

      BufferedReader br = new BufferedReader(new InputStreamReader((conn.getInputStream())));
      String output;
      while ((output = br.readLine()) != null) {
        System.out.println(output);
      }
      conn.disconnect();
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
}
require "uri"
require "net/http"

url = URI("<your webhook URL>")
https = Net::HTTP.new(url.host, url.port)
https.use_ssl = true

request = Net::HTTP::Post.new(url)
request["Content-Type"] = "application/json"
request["Wave-Signature"] = "t=1667920421,v1=53c971695230e9c51b1030d673eee76e70bbcdf8a7c5b8c1d44e0b8b1329647b"
request.body = "{\"id\":\"AE_ijzo7oGgrlM7\",\"type\":\"checkout.session.completed\",\"data\":{\"id\":\"cos-1b01sghpg100j\",\"amount\":\"100\",\"checkout_status\":\"complete\",\"client_reference\":null,\"currency\":\"XOF\",\"error_url\":\"https://example.com/error\",\"last_payment_error\":null,\"business_name\":\"Annas Apiaries\",\"payment_status\":\"succeeded\",\"success_url\":\"https://example.com/success\",\"wave_launch_url\":\"https://pay.wave.com/c/cos-1b01sghpg100j?a=100&c=XOF&m=Annas%20Apiaries\",\"when_completed\":\"2022-11-08T15:05:45Z\",\"when_created\":\"2022-11-08T15:05:32Z\",\"when_expires\":\"2022-11-09T15:05:32Z\",\"transaction_id\":\"TCN4Y4ZC3FM\"}"

response = https.request(request)
puts response.read_body
import requests

url = '<your webhook URL>'
payload = "{\"id\": \"AE_ijzo7oGgrlM7\", \"type\": \"checkout.session.completed\", \"data\": {\"id\": \"cos-1b01sghpg100j\", \"amount\": \"100\", \"checkout_status\": \"complete\", \"client_reference\": null, \"currency\": \"XOF\", \"error_url\": \"https://example.com/error\", \"last_payment_error\": null, \"business_name\": \"Annas Apiaries\", \"payment_status\": \"succeeded\", \"success_url\": \"https://example.com/success\", \"wave_launch_url\": \"https://pay.wave.com/c/cos-1b01sghpg100j?a=100&c=XOF&m=Annas%20Apiaries\", \"when_completed\": \"2022-11-08T15:05:45Z\", \"when_created\": \"2022-11-08T15:05:32Z\", \"when_expires\": \"2022-11-09T15:05:32Z\", \"transaction_id\": \"TCN4Y4ZC3FM\"}"
headers = {
  'Content-Type': 'application/json',
  'Wave-Signature': 't=1667920421,v1=53c971695230e9c51b1030d673eee76e70bbcdf8a7c5b8c1d44e0b8b1329647b'
}

response = requests.request("POST", url, headers=headers, data = payload)

print(response.text.encode('utf8'))
package main

import (
  "bytes"
  "fmt"
  "net/http"
)

func main() {
  url := "<your webhook URL>"
  method := "POST"

  payload := &bytes.Buffer{}
  payload.WriteString(`{"id": "AE_ijzo7oGgrlM7", "type": "checkout.session.completed", "data": {"id": "cos-1b01sghpg100j", "amount": "100", "checkout_status": "complete", "client_reference": null, "currency": "XOF", "error_url": "https://example.com/error", "last_payment_error": null, "business_name": "Annas Apiaries", "payment_status": "succeeded", "success_url": "https://example.com/success", "wave_launch_url": "https://pay.wave.com/c/cos-1b01sghpg100j?a=100&c=XOF&m=Annas%20Apiaries", "when_completed": "2022-11-08T15:05:45Z", "when_created": "2022-11-08T15:05:32Z", "when_expires": "2022-11-09T15:05:32Z", "transaction_id": "TCN4Y4ZC3FM"}`)
  client := &http.Client{}
  req, err := http.NewRequest(method, url, payload)

  if err != nil {
    fmt.Println(err)
    return
  }
  req.Header.Add("Content-Type", "application/json")
  req.Header.Add("Wave-Signature", "t=1667920421,v1=53c971695230e9c51b1030d673eee76e70bbcdf8a7c5b8c1d44e0b8b1329647b")

  res, err := client.Do(req)
  if err != nil {
    fmt.Println(err)
    return
  }
  defer res.Body.Close()

  fmt.Println(res)
}
using System;
using System.IO;
using System.Net;
using System.Text;

class Program
{
  static void Main()
  {
    var request = (HttpWebRequest)WebRequest.Create("<your webhook URL>");
    request.Method = "POST";
    request.ContentType = "application/json";
    request.Headers.Add("Wave-Signature", "t=1667920421,v1=53c971695230e9c51b1030d673eee76e70bbcdf8a7c5b8c1d44e0b8b1329647b");

    var data = Encoding.ASCII.GetBytes("{\"id\": \"AE_ijzo7oGgrlM7\", \"type\": \"checkout.session.completed\", \"data\": {\"id\": \"cos-1b01sghpg100j\", \"amount\": \"100\", \"checkout_status\": \"complete\", \"client_reference\": null, \"currency\": \"XOF\", \"error_url\": \"https://example.com/error\", \"last_payment_error\": null, \"business_name\": \"Annas Apiaries\", \"payment_status\": \"succeeded\", \"success_url\": \"https://example.com/success\", \"wave_launch_url\": \"https://pay.wave.com/c/cos-1b01sghpg100j?a=100&c=XOF&m=Annas%20Apiaries\", \"when_completed\": \"2022-11-08T15:05:45Z\", \"when_created\": \"2022-11-08T15:05:32Z\", \"when_expires\": \"2022-11-09T15:05:32Z\", \"transaction_id\": \"TCN4Y4ZC3FM\"}");

    using (var stream = request.GetRequestStream())
    {
      stream.Write(data, 0, data.Length);
    }

    var response = (HttpWebResponse)request.GetResponse();

    var responseString = new StreamReader(response.GetResponseStream()).ReadToEnd();
    Console.WriteLine(responseString);
  }
}

Retries

If your server does not respond with a successful status code (HTTP 2xx) to a webhook notification, Wave will retry sending the notification for up to 3 days. This is to ensure that you do not miss any important events.

When you receive a webhook notification, you should respond with a successful status code (HTTP 2xx) before performing any business logic. This is to ensure that Wave does not retry sending the notification.

Wave will make every attempt to successfully deliver a webhook event only a single time. However, due to the nature of distributed systems, we cannot guarantee that a webhook event will be delivered at all, that it will only be delivered once, and or that it will be delivered in any particular order in relation to other any other events. Your system must be robust to missing, duplicate, and out-of-order webhook events.