Partners

Partner postbacks and signature verification

How a ForestVPN partner server receives postbacks: the events, how to verify the X-FVPN-Signature header, working code in nine languages, and the retries.

Updated October 29, 2026

On this page (14)

A postback is one HTTPS request ForestVPN makes to your server when something happens to a customer you sent us: they register, start a trial, pay, renew or are refunded. This page is for the developer who receives those requests. It covers setting up an endpoint, what each request contains, how to check that it really came from ForestVPN, and complete verification code in nine languages.

Set up an endpoint

Sign in at partners.fvpn.net and open Postbacks. A team owner chooses Add an endpoint, up to five in all, and sets:

  • Method: GET or POST. A GET puts the values in the address. A POST also sends them as a JSON body; its address may still use macros.
  • Address: the https URL we call, with macros where you want values. For example https://partner.example/pb?cid={click_id}&ev={event}&amt={amount}.
  • Events: the events this endpoint wants, at least one.
  • Timeout (ms): how long we wait for your answer, 1000 to 10000, default 5000.
  • Send postbacks to this endpoint: switch it off to stop sending to it. An event that happens while the endpoint is off is not sent later and cannot be replayed. A delivery already waiting or retrying when you switch it off ends as failed (url).

When you save a new endpoint the dashboard shows its signing secret once. Copy it into your server's configuration before you close the message. If you lose it, rotate it (see Your secret and rotation).

Events

An endpoint lists the events it wants, a non-empty comma list of:

EventWhen{amount}{rail}
registrationA customer created their account through your link.0.00empty
trial_startThe customer started a store free trial.0.00apple or google
first_paymentThe customer's first payment (cycle 1).what they paidweb, apple or google
renewalAny later payment (cycle 2, 3, ...).what they paidweb, apple or google
refundA payment was refunded, in full or in part. One event per refund.negativeas the payment
chargebackA payment was disputed and taken back.negativeas the payment

Events arrive one request each. They can arrive late and out of order after a retry: sort by the number after the dot in event_id (see Identifiers and dedupe) or by ts, never by arrival.

A paid event can carry {amount} 0.00 when we have no exchange rate for that day or know no price for the payment, and a percentage commission is 0.00 with it. Decide whether an event was a payment from event, never from the amount.

A customer whose commission window has ended (the plan's months or cycles limit) produces no more postbacks. A refund or chargeback follows its payment: it is sent when the payment's postback was. It can arrive after the commission window has ended, with click_id and sub1 .. sub5 empty: identify the customer by conversion_id, which stays. While your partner account is suspended no postbacks are created.

Macros

A GET endpoint puts macros in the URL. A POST endpoint may use them in the URL too. Values are percent-encoded: RFC 3986 unreserved characters stay as they are, every other byte becomes %XX.

MacroValue
{click_id} {sub1} .. {sub5}Your values from the click, or empty. Always empty once the customer's account is deleted, and after the commission window ends.
{event}registration trial_start first_payment renewal refund chargeback
{event_id}See Identifiers and dedupe.
{conversion_id}See Identifiers and dedupe.
{amount}What the customer paid, in US dollars at the payment day's rate (negative on a reversal), for example 49.99 or -49.99; 0.00 for registration and trial.
{currency}Always USD (kept so templates that expect it keep working).
{commission}Your commission for this event in US dollars, for example 14.56, -14.56 or 0.00. It is pending: it may later be rejected, and it is paid only on the statement the dashboard shows.
{rail}web (any card or crypto checkout), apple or google; empty for registration.
{ts}When the event happened, Unix seconds.

Put {click_id} and {sub1} .. {sub5} in the query string, not as a whole path segment. Their values may contain dots, and a value of . or .. in a path segment makes the URL fail the check at send time: the delivery ends failed (url) although the template passed when you saved it.

An unknown {...} is refused when you save the template (macro_unknown). So is a macro in the scheme, host or port: a click value must never choose where a postback goes. A template whose fixed part would change when a browser-grade URL parser reads and re-writes it (an uppercase host, a .. segment, a space, an explicit :443, a path-less https://host?x=1) is refused too (url_refused), because the signature covers the re-written URL and it must be the URL we send.

The POST body

A POST sends Content-Type: application/json with these keys, in this order, always all present. They are strings, except v and ts; an absent value is an empty string.

{"v":1,"event_id":"6f1c0e2a9b7d4c3e8a5f0b1d2c3e4f50.2","event":"first_payment","conversion_id":"6f1c0e2a9b7d4c3e8a5f0b1d2c3e4f50","click_id":"abc123","sub1":"","sub2":"","sub3":"","sub4":"","sub5":"","amount":"49.99","currency":"USD","commission":"14.56","rail":"web","ts":1767225000}

Sign over the bytes exactly as received: do not parse and re-serialise before verifying.

What is never sent

No account number, email, device, country, IP address, store currency, exact payment rail, product, price list or any other customer's data. The amount is in US dollars and the rail is grouped (web, apple, google) on purpose: a store currency such as TRY, KZT or AED, or the RUB-only card rail, would tell you the customer's country. One residual you should know about: a US-dollar amount still shows a price level, which narrows the customer's storefront to the band of countries that share it.

Identifiers and dedupe

  • conversion_id identifies one customer to you across all their events: 32 lowercase hex characters, stable for as long as the attribution lasts (a phone-number change or an account merge keeps it). It tells you nothing about their ForestVPN account.
  • event_id is <conversion_id>.<seq> with seq counting up per customer. It is unique per event. Numbers can skip, because a conversion that sent nothing (out of window, an endpoint that did not want the event) still takes one: never treat a gap as a missing event. Dedupe on event_id. A request can reach you twice (a re-send after a queue hiccup, a replay from your dashboard); the copy has the same event_id and a fresh signature timestamp.
  • A test postback (see Test your endpoint) uses event_id = test.<16 hex> and a conversion_id of 32 zeros.

Verifying a request

Every request carries:

HeaderValue
X-FVPN-Signaturet=<unix seconds>,v1=<hex>
X-FVPN-Event-Idthe event_id
User-AgentForestVPN-Postback/1

and, for a POST, Content-Type: application/json. We set no other header of our own and send no cookies; the network adds transport headers such as Host and Content-Length, so do not reject a request for carrying more. t is read when we start a batch of deliveries, so it can be a minute or two older than the moment the request reaches you; the 300 s window allows for that.

To verify:

  1. Split X-FVPN-Signature into t and v1.

  2. Reject if |now - t| > 300 seconds.

  3. Build the canonical string: six lines joined by a single newline, with no newline at the end.

    v1
    <t>
    <event_id>
    <METHOD>
    <URL>
    <hex SHA-256 of the body>
    • event_id is the X-FVPN-Event-Id header value. It is signed, so nobody replaying a request inside the 300 s can change it to get past your dedupe.
    • METHOD is GET or POST, uppercase.
    • URL is the full URL as requested, https:// scheme included, with the query string exactly as we sent it. Behind a proxy or framework, rebuild it from the raw request target: do not let a framework decode and re-encode the query.
    • body is the exact bytes received: empty for GET.
  4. v1 must equal hex(HMAC-SHA256(key, canonical)), where the key is the secret string as ASCII bytes including the whsec_ prefix. Compare in constant time.

  5. Dedupe by event_id.

A replay has a new t and the same event_id, so a signature is never reusable for a different event.

Test vectors

Secret whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef, t = 1767225600. Your code must reproduce both v1 values when now is 1767225600.

GET. URL https://partner.example/pb?cid=abc123&ev=first_payment&amt=49.99&cur=USD&com=14.56&conv=6f1c0e2a9b7d4c3e8a5f0b1d2c3e4f50&eid=6f1c0e2a9b7d4c3e8a5f0b1d2c3e4f50.2, X-FVPN-Event-Id: 6f1c0e2a9b7d4c3e8a5f0b1d2c3e4f50.2, empty body (SHA-256 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855):

X-FVPN-Signature: t=1767225600,v1=a51e33485ee30d3dec28c6598c52be7f44c39626adf0d05f74b166a93e7bbd2d

POST. URL https://partner.example/pb, the same X-FVPN-Event-Id, body (one line, 284 bytes, SHA-256 575e434067aa4818236f397bdda99e338bbb700b400fc1ea9ca0a4e97096a9cc):

{"v":1,"event_id":"6f1c0e2a9b7d4c3e8a5f0b1d2c3e4f50.2","event":"first_payment","conversion_id":"6f1c0e2a9b7d4c3e8a5f0b1d2c3e4f50","click_id":"abc123","sub1":"","sub2":"","sub3":"","sub4":"","sub5":"","amount":"49.99","currency":"USD","commission":"14.56","rail":"web","ts":1767225000}
X-FVPN-Signature: t=1767225600,v1=8dce4232fa01045850f06646410f2be45094aac340ec9531ac2ff492642b44ca

The secret is the published test secret. It signs nothing real; your own endpoint secret is different and is shown only to you.

Code

Ten complete verifiers in nine languages (JavaScript has two, one for Node.js and one that uses only Web Crypto): Python, Node.js, Web Crypto, Go, PHP, Ruby, Java, C#, Rust and Shell. Each is a single file with nothing to install beyond the language's standard library. The exception is Rust, a Cargo.toml and a main.rs that use the hmac and sha2 crates.

Each file has one function, verify(secret, signature, event_id, method, url, body, now), that returns true or false (Verify in Go, Check in C#, and async in Web Crypto). It rejects a header that is not exactly t=<digits>,v1=<64 lowercase hex> and a t more than 300 seconds from now. Then it builds the canonical string, signs it with the whole secret (the whsec_ prefix included) and compares without leaking timing. Copy the function into your handler. The few lines under it only read the command line, and you can delete them. Deduplicating on event_id is yours to add. A request without X-FVPN-Signature or X-FVPN-Event-Id is not from us: answer 401 before you call the function, because a missing header is not a string it can check.

Two things go wrong more often than the cryptography:

  1. The body must be the raw bytes. Read it before any JSON parser sees it. A body that was parsed and written out again hashes differently. For a GET the body is empty.
  2. The URL must be the one we called. Write your own https:// origin in your code, and add the request target exactly as received, path and query. Do not build the URL from the Host header or from a framework helper that decodes and re-encodes or re-orders the query: behind a proxy they give http://, an internal host or a different query.

Every file also runs from the command line, so you can check it against the test vectors. The arguments are the secret, the signature header, the event id, the method, the full URL and, last, now in Unix seconds (default: the current time). The request body goes on standard input, byte for byte; use </dev/null for a GET. It prints true or false. This is the GET vector:

python3 verify.py \
  'whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' \
  't=1767225600,v1=a51e33485ee30d3dec28c6598c52be7f44c39626adf0d05f74b166a93e7bbd2d' \
  '6f1c0e2a9b7d4c3e8a5f0b1d2c3e4f50.2' GET \
  'https://partner.example/pb?cid=abc123&ev=first_payment&amt=49.99&cur=USD&com=14.56&conv=6f1c0e2a9b7d4c3e8a5f0b1d2c3e4f50&eid=6f1c0e2a9b7d4c3e8a5f0b1d2c3e4f50.2' \
  1767225600 </dev/null

Python

Python 3, standard library only.

verify.py
"""Verify a ForestVPN postback. Standard library only.
 
  python3 verify.py SECRET SIGNATURE EVENT_ID METHOD URL [NOW] < body
Prints true or false. Copy verify() into your handler; the rest is a test harness.
"""
import hashlib, hmac, re, sys, time
 
 
def verify(secret, signature, event_id, method, url, body, now):
    """secret: the whole whsec_... string. signature: the X-FVPN-Signature header value.
    event_id: the X-FVPN-Event-Id header value. url: the full URL as requested (https://...).
    body: the exact bytes received. now: Unix seconds."""
    m = re.fullmatch(r"t=([0-9]{1,15}),v1=([0-9a-f]{64})", signature)
    if not m:
        return False
    t = int(m[1])
    if abs(now - t) > 300:
        return False
    canonical = "\n".join(["v1", str(t), event_id, method, url, hashlib.sha256(body).hexdigest()])
    expected = hmac.new(secret.encode("ascii"), canonical.encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, m[2])
 
 
if __name__ == "__main__":
    secret, signature, event_id, method, url, *rest = sys.argv[1:]
    now = int(rest[0]) if rest else int(time.time())
    print("true" if verify(secret, signature, event_id, method, url, sys.stdin.buffer.read(), now) else "false")

Flask. request.get_data() returns the raw body bytes; call it before request.get_json() or request.form. request.query_string is the raw query as bytes, so the URL is ORIGIN + request.path + "?" + request.query_string.decode(), without the ? when there is no query. Django. request.body is the raw body (read it before request.POST) and request.META["QUERY_STRING"] is the raw query, so the URL is ORIGIN + request.path, plus "?" + request.META["QUERY_STRING"] when that is not empty. Do not use request.build_absolute_uri(); it builds the URL from the Host header. Both frameworks hand you a percent-decoded path, so these match only while the fixed path of your address has no %XX escape. If it has, read the server's raw request target instead: RAW_URI in the WSGI environment under gunicorn (request.environ["RAW_URI"] in Flask, request.META["RAW_URI"] in Django), REQUEST_URI under uWSGI. It already includes the query. Read the headers as request.headers["X-FVPN-Signature"] and request.headers["X-FVPN-Event-Id"].

Node.js

Node.js 18 or newer, node:crypto only.

verify.mjs
// Verify a ForestVPN postback with node:crypto (Node 18+).
//   node verify.mjs SECRET SIGNATURE EVENT_ID METHOD URL [NOW] < body
// Prints true or false. Copy verify() into your handler; the rest is a test harness.
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
 
// secret: the whole whsec_... string. signature: the X-FVPN-Signature header value.
// eventId: the X-FVPN-Event-Id header value. url: the full URL as requested (https://...).
// body: the exact bytes received (Buffer). now: Unix seconds.
export function verify(secret, signature, eventId, method, url, body, now) {
  const m = /^t=([0-9]{1,15}),v1=([0-9a-f]{64})$/.exec(signature);
  if (!m) return false;
  const t = Number(m[1]);
  if (Math.abs(now - t) > 300) return false;
  const canonical = ["v1", String(t), eventId, method, url, createHash("sha256").update(body).digest("hex")].join("\n");
  const expected = createHmac("sha256", secret).update(canonical).digest("hex");
  return timingSafeEqual(Buffer.from(expected), Buffer.from(m[2])); // both 64 ASCII bytes
}
 
if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const [secret, signature, eventId, method, url, now] = process.argv.slice(2);
  const n = now === undefined ? Math.floor(Date.now() / 1000) : Number(now);
  console.log(verify(secret, signature, eventId, method, url, readFileSync(0), n));
}

Express. Mount express.raw({ type: "*/*" }) on the route, before any express.json(). req.body is then a Buffer for a POST; for a GET Express 4 leaves {} there, so pass Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0). The URL is ORIGIN + req.originalUrl, which is the path and query exactly as received. The headers are req.get("X-FVPN-Signature") and req.get("X-FVPN-Event-Id"). With the plain http module, req.url is the raw target and the body comes from the request stream.

Web Crypto

For Cloudflare Workers, Deno, Bun and Node.js 20 or newer. It uses crypto.subtle only, and verify is async.

verify-webcrypto.mjs
// Verify a ForestVPN postback with Web Crypto only: Cloudflare Workers, Deno, Bun, Node 20+.
//   node verify-webcrypto.mjs SECRET SIGNATURE EVENT_ID METHOD URL [NOW] < body
// Prints true or false. Copy verify() into your handler; the rest is a test harness.
const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
 
// secret: the whole whsec_... string. signature: the X-FVPN-Signature header value.
// eventId: the X-FVPN-Event-Id header value. url: the full URL as requested (https://...).
// body: the exact bytes received (Uint8Array, from await request.arrayBuffer()). now: Unix seconds.
export async function verify(secret, signature, eventId, method, url, body, now) {
  const m = /^t=([0-9]{1,15}),v1=([0-9a-f]{64})$/.exec(signature);
  if (!m) return false;
  const t = Number(m[1]);
  if (Math.abs(now - t) > 300) return false;
  const enc = new TextEncoder();
  const canonical = ["v1", String(t), eventId, method, url, hex(await crypto.subtle.digest("SHA-256", body))].join("\n");
  const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
  const sig = Uint8Array.from(m[2].match(/../g), (h) => parseInt(h, 16));
  return crypto.subtle.verify("HMAC", key, sig, enc.encode(canonical)); // constant time
}
 
if (typeof process !== "undefined" && process.argv[1]?.endsWith("verify-webcrypto.mjs")) {
  const chunks = [];
  for await (const c of process.stdin) chunks.push(c);
  const body = new Uint8Array(await new Blob(chunks).arrayBuffer());
  const [secret, signature, eventId, method, url, now] = process.argv.slice(2);
  const n = now === undefined ? Math.floor(Date.now() / 1000) : Number(now);
  console.log(await verify(secret, signature, eventId, method, url, body, n));
}

Cloudflare Workers. new Uint8Array(await request.arrayBuffer()) is the raw body; read it before request.json(). request.url is the URL we signed, because a template that a browser-grade URL parser would rewrite is refused when you save it. The headers are request.headers.get("X-FVPN-Signature") and request.headers.get("X-FVPN-Event-Id"). Deno and Bun. The body is req.arrayBuffer() and the headers are req.headers.get(...), as on Workers. req.url is not the URL we signed: it follows the Host header and the connection the process itself accepted, so behind a proxy or load balancer it reads http:// or an internal host. Use const u = new URL(req.url); ORIGIN + u.pathname + u.search with your own https:// origin.

Go

Standard library only; go run verify.go runs it.

verify.go
// Verify a ForestVPN postback. Standard library only.
//
//	go run verify.go SECRET SIGNATURE EVENT_ID METHOD URL [NOW] < body
//
// Prints true or false. Copy Verify() into your handler; main is a test harness.
package main
 
import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"os"
	"regexp"
	"strconv"
	"time"
)
 
var signatureRe = regexp.MustCompile(`^t=([0-9]{1,15}),v1=([0-9a-f]{64})$`)
 
// Verify reports whether a postback is authentic and fresh. secret is the whole whsec_... string,
// signature the X-FVPN-Signature header, eventID the X-FVPN-Event-Id header, url the full URL as
// requested (https://...), body the exact bytes received, now Unix seconds.
func Verify(secret, signature, eventID, method, url string, body []byte, now int64) bool {
	m := signatureRe.FindStringSubmatch(signature)
	if m == nil {
		return false
	}
	t, _ := strconv.ParseInt(m[1], 10, 64)
	if d := now - t; d > 300 || d < -300 {
		return false
	}
	sum := sha256.Sum256(body)
	canonical := fmt.Sprintf("v1\n%d\n%s\n%s\n%s\n%s", t, eventID, method, url, hex.EncodeToString(sum[:]))
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(canonical))
	given, _ := hex.DecodeString(m[2])
	return hmac.Equal(mac.Sum(nil), given) // constant time
}
 
func main() {
	a := os.Args[1:]
	if len(a) < 5 {
		fmt.Fprintln(os.Stderr, "usage: verify SECRET SIGNATURE EVENT_ID METHOD URL [NOW] < body")
		os.Exit(2)
	}
	now := time.Now().Unix()
	if len(a) > 5 {
		now, _ = strconv.ParseInt(a[5], 10, 64)
	}
	body, _ := io.ReadAll(os.Stdin)
	fmt.Println(Verify(a[0], a[1], a[2], a[3], a[4], body, now))
}

net/http. Read the body with io.ReadAll(r.Body). r.RequestURI is the request target exactly as the client sent it, so the URL is "https://partner.example" + r.RequestURI. r.URL.String() is rebuilt and can differ. The headers are r.Header.Get("X-FVPN-Signature") and r.Header.Get("X-FVPN-Event-Id"); the method is r.Method; now is time.Now().Unix().

PHP

PHP 7.2 or newer, no extensions beyond the standard ones.

verify.php
<?php
// Verify a ForestVPN postback. PHP 7.2+, no extensions beyond the standard ones.
//   php verify.php SECRET SIGNATURE EVENT_ID METHOD URL [NOW] < body
// Prints true or false. Copy verify() into your handler; the rest is a test harness.
 
// $secret: the whole whsec_... string. $signature: the X-FVPN-Signature header value.
// $eventId: the X-FVPN-Event-Id header value. $url: the full URL as requested (https://...).
// $body: the exact bytes received (file_get_contents('php://input')). $now: Unix seconds.
function verify(string $secret, string $signature, string $eventId, string $method, string $url, string $body, int $now): bool
{
    if (!preg_match('/\At=([0-9]{1,15}),v1=([0-9a-f]{64})\z/', $signature, $m)) {
        return false;
    }
    $t = (int) $m[1];
    if (abs($now - $t) > 300) {
        return false;
    }
    $canonical = implode("\n", ['v1', (string) $t, $eventId, $method, $url, hash('sha256', $body)]);
    return hash_equals(hash_hmac('sha256', $canonical, $secret), $m[2]);
}
 
if (PHP_SAPI === 'cli' && realpath($argv[0]) === __FILE__) {
    [, $secret, $signature, $eventId, $method, $url] = $argv + array_fill(0, 6, '');
    $now = isset($argv[6]) ? (int) $argv[6] : time();
    echo verify($secret, $signature, $eventId, $method, $url, stream_get_contents(STDIN), $now) ? "true\n" : "false\n";
}

Plain PHP. file_get_contents('php://input') is the raw body and $_SERVER['REQUEST_URI'] is the raw target, so the URL is $origin . $_SERVER['REQUEST_URI']. The headers are $_SERVER['HTTP_X_FVPN_SIGNATURE'] and $_SERVER['HTTP_X_FVPN_EVENT_ID']. Laravel. $request->getContent() is the raw body and $request->getRequestUri() is the raw target. Do not use $request->fullUrl() or getUri(): they normalise the query string, which re-orders and re-escapes it. Read the headers with $request->header('X-FVPN-Signature'). Put the route outside the CSRF middleware (routes/api.php has none); the signature is its check.

Ruby

Ruby 3.0 or newer, openssl only.

verify.rb
# Verify a ForestVPN postback. Ruby 3.0+, standard library only.
#   ruby verify.rb SECRET SIGNATURE EVENT_ID METHOD URL [NOW] < body
# Prints true or false. Copy verify() into your handler; the rest is a test harness.
require "openssl"
 
# secret: the whole whsec_... string. signature: the X-FVPN-Signature header value.
# event_id: the X-FVPN-Event-Id header value. url: the full URL as requested (https://...).
# body: the exact bytes received (request.body.read). now: Unix seconds.
def verify(secret, signature, event_id, method, url, body, now)
  m = /\At=([0-9]{1,15}),v1=([0-9a-f]{64})\z/.match(signature) or return false
  t = m[1].to_i
  return false if (now - t).abs > 300
  canonical = ["v1", t.to_s, event_id, method, url, OpenSSL::Digest::SHA256.hexdigest(body)].join("\n")
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, canonical)
  OpenSSL.fixed_length_secure_compare(expected, m[2]) # constant time; both are 64 characters
end
 
if $PROGRAM_NAME == __FILE__
  secret, signature, event_id, method, url, now = ARGV
  puts verify(secret, signature, event_id, method, url, $stdin.binmode.read, now ? now.to_i : Time.now.to_i)
end

Rails. request.raw_post is the raw body, and request.original_fullpath is the path and query as requested, so the URL is ORIGIN + request.original_fullpath. Read the headers as request.headers["X-FVPN-Signature"] and request.headers["X-FVPN-Event-Id"]. Skip CSRF protection on this one action (skip_forgery_protection); the signature is its check. Do not take the body from params, which has already been parsed.

Java

Java 17 or newer, standard library only. The class is Verify, so the file must be named Verify.java.

Verify.java
// Verify a ForestVPN postback. Java 17+, standard library only.
//   javac Verify.java && java Verify SECRET SIGNATURE EVENT_ID METHOD URL [NOW] < body
// Prints true or false. Copy verify() into your handler; main is a test harness.
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
 
public class Verify {
    private static final Pattern SIGNATURE = Pattern.compile("t=([0-9]{1,15}),v1=([0-9a-f]{64})");
 
    /** secret: the whole whsec_... string. signature: the X-FVPN-Signature header value.
     *  eventId: the X-FVPN-Event-Id header value. url: the full URL as requested (https://...).
     *  body: the exact bytes received. now: Unix seconds. */
    public static boolean verify(String secret, String signature, String eventId, String method,
                                 String url, byte[] body, long now) throws Exception {
        Matcher m = SIGNATURE.matcher(signature);
        if (!m.matches()) return false;
        long t = Long.parseLong(m.group(1));
        if (Math.abs(now - t) > 300) return false;
        String canonical = String.join("\n", "v1", Long.toString(t), eventId, method, url,
                HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(body)));
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.US_ASCII), "HmacSHA256"));
        String expected = HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8)));
        return MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII),
                m.group(2).getBytes(StandardCharsets.US_ASCII)); // constant time
    }
 
    public static void main(String[] a) throws Exception {
        long now = a.length > 5 ? Long.parseLong(a[5]) : System.currentTimeMillis() / 1000;
        System.out.println(verify(a[0], a[1], a[2], a[3], a[4], System.in.readAllBytes(), now));
    }
}

Spring. Take the body as @RequestBody(required = false) byte[] body, which hands over the raw bytes; it is null for a GET, so treat null as an empty array (new byte[0]). Without required = false Spring answers a GET with 400 before your code runs, and we do not retry a 400. Take the request as HttpServletRequest request: request.getRequestURI() and request.getQueryString() are both the raw, undecoded values, so the URL is ORIGIN + request.getRequestURI() + (request.getQueryString() == null ? "" : "?" + request.getQueryString()). Do not use request.getRequestURL() behind a proxy. The headers are @RequestHeader("X-FVPN-Signature") and @RequestHeader("X-FVPN-Event-Id"). Do not bind the body to an object; you would no longer have the bytes.

C#

.NET 8 or newer, standard library only. On .NET 10 dotnet run Verify.cs -- ... runs it as it is; on .NET 8 or 9 put it in a project.

Verify.cs
// Verify a ForestVPN postback. .NET 8+, standard library only.
//   dotnet run Verify.cs -- SECRET SIGNATURE EVENT_ID METHOD URL [NOW] < body     (.NET 10 file-based app)
// Prints true or false. Copy Verify.Check() into your handler; Main is a test harness.
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
 
static class Verify
{
    static readonly Regex Signature = new(@"\At=([0-9]{1,15}),v1=([0-9a-f]{64})\z");
 
    // secret: the whole whsec_... string. signature: the X-FVPN-Signature header value.
    // eventId: the X-FVPN-Event-Id header value. url: the full URL as requested (https://...).
    // body: the exact bytes received. now: Unix seconds.
    public static bool Check(string secret, string signature, string eventId, string method,
                             string url, byte[] body, long now)
    {
        var m = Signature.Match(signature);
        if (!m.Success) return false;
        long t = long.Parse(m.Groups[1].Value);
        if (Math.Abs(now - t) > 300) return false;
        string canonical = string.Join("\n", "v1", t.ToString(), eventId, method, url,
            Convert.ToHexString(SHA256.HashData(body)).ToLowerInvariant());
        string expected = Convert.ToHexString(
            HMACSHA256.HashData(Encoding.ASCII.GetBytes(secret), Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant();
        return CryptographicOperations.FixedTimeEquals(
            Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(m.Groups[2].Value));
    }
 
    static void Main(string[] a)
    {
        long now = a.Length > 5 ? long.Parse(a[5]) : DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        using var body = new MemoryStream();
        Console.OpenStandardInput().CopyTo(body);
        Console.WriteLine(Check(a[0], a[1], a[2], a[3], a[4], body.ToArray(), now) ? "true" : "false");
    }
}

ASP.NET Core. Copy Request.Body into a MemoryStream with CopyToAsync and take ToArray(); do this before model binding reads it, so do not bind the body to an object. The raw target is HttpContext.Features.Get<IHttpRequestFeature>()!.RawTarget, so the URL is ORIGIN + RawTarget. Request.Path is already decoded, and Request.Host and Request.Scheme follow the proxy, so use your own origin. The headers are Request.Headers["X-FVPN-Signature"] and Request.Headers["X-FVPN-Event-Id"], and the method is Request.Method. Call Verify.Check.

Rust

Two files of one Cargo project. The two crates are hmac and sha2, and Cargo.toml pins both to an exact version. Their own dependencies resolve as Cargo chooses: keep your Cargo.lock in version control.

verify-rs/Cargo.toml
[package]
name = "verify-rs"
version = "0.1.0"
edition = "2021"
publish = false
 
# Its own workspace: this example is copied by partners and is not part of the ForestVPN build.
[workspace]
 
[dependencies]
hmac = "=0.12.1"
sha2 = "=0.10.8"
verify-rs/src/main.rs
//! Verify a ForestVPN postback. Dependencies: hmac and sha2 (RustCrypto).
//!   cargo run --release -- SECRET SIGNATURE EVENT_ID METHOD URL [NOW] < body
//! Prints true or false. Copy verify() into your handler; main is a test harness.
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
use std::io::Read;
 
/// secret: the whole whsec_... string. signature: the X-FVPN-Signature header value.
/// event_id: the X-FVPN-Event-Id header value. url: the full URL as requested (https://...).
/// body: the exact bytes received. now: Unix seconds.
fn verify(secret: &str, signature: &str, event_id: &str, method: &str, url: &str, body: &[u8], now: i64) -> bool {
    // t=<1-15 digits>,v1=<64 lowercase hex>
    let Some((t, v1)) = signature.strip_prefix("t=").and_then(|s| s.split_once(",v1=")) else { return false };
    if t.is_empty() || t.len() > 15 || !t.bytes().all(|b| b.is_ascii_digit()) {
        return false;
    }
    if v1.len() != 64 || !v1.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
        return false;
    }
    let Ok(t) = t.parse::<i64>() else { return false };
    if (now - t).abs() > 300 {
        return false;
    }
    let body_hash: String = Sha256::digest(body).iter().map(|b| format!("{b:02x}")).collect();
    let canonical = format!("v1\n{t}\n{event_id}\n{method}\n{url}\n{body_hash}");
    let given: Vec<u8> = (0..64).step_by(2).map(|i| u8::from_str_radix(&v1[i..i + 2], 16).unwrap_or(0)).collect();
    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC takes any key length");
    mac.update(canonical.as_bytes());
    mac.verify_slice(&given).is_ok() // constant time
}
 
fn main() {
    let a: Vec<String> = std::env::args().skip(1).collect();
    if a.len() < 5 {
        eprintln!("usage: verify-rs SECRET SIGNATURE EVENT_ID METHOD URL [NOW] < body");
        std::process::exit(2);
    }
    let now = a.get(5).and_then(|n| n.parse().ok()).unwrap_or_else(|| {
        std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)
    });
    let mut body = Vec::new();
    std::io::stdin().read_to_end(&mut body).expect("read stdin");
    println!("{}", verify(&a[0], &a[1], &a[2], &a[3], &a[4], &body, now));
}

axum. Take body: axum::body::Bytes as the last extractor of the handler: it is the raw body, where Json<T> and String would parse or check it first. Take OriginalUri(uri): OriginalUri for the target, so the URL is format!("https://partner.example{}", uri.path_and_query().map_or("", |p| p.as_str())); OriginalUri is the target before a nested router strips its prefix. The headers come from a HeaderMap extractor: headers.get("x-fvpn-signature") and headers.get("x-fvpn-event-id"). verify is private in main.rs; make it pub if you move it.

Shell

bash 3.2 or newer and openssl. The secret goes to openssl on its command line, so other users of the machine can see it in ps: use this one for tests and tiny relays, not on a shared host.

verify.sh
#!/usr/bin/env bash
# Verify a ForestVPN postback with bash and openssl.
#   ./verify.sh SECRET SIGNATURE EVENT_ID METHOD URL [NOW] < body
# Prints true or false. Source this file to use verify() from your own script.
# Needs bash 3.2+ and openssl. The secret is passed to openssl on its command line, so other users
# of the machine can see it in `ps`: use this for tests and tiny relays, not on a shared host.
 
hmac_hex() { openssl dgst -sha256 -r -hmac "$1" | cut -d' ' -f1; } # message on stdin
 
# verify SECRET SIGNATURE EVENT_ID METHOD URL NOW < body
#   SECRET: the whole whsec_... string. SIGNATURE: the X-FVPN-Signature header value.
#   EVENT_ID: the X-FVPN-Event-Id header value. URL: the full URL as requested (https://...).
#   body on stdin: the exact bytes received. NOW: Unix seconds.
verify() {
  local secret=$1 signature=$2 event_id=$3 method=$4 url=$5 now=$6 t d body_hash expected k
  [[ $signature =~ ^t=([0-9]{1,15}),v1=([0-9a-f]{64})$ ]] || return 1
  t=$((10#${BASH_REMATCH[1]})) # 10# so a leading zero is not read as octal
  d=$((now - t)); ((d < 0)) && d=$((-d))
  ((d <= 300)) || return 1
  body_hash=$(openssl dgst -sha256 -r | cut -d' ' -f1)
  expected=$(printf 'v1\n%s\n%s\n%s\n%s\n%s' "$t" "$event_id" "$method" "$url" "$body_hash" | hmac_hex "$secret")
  # The shell has no constant-time compare. Comparing two HMACs under a fresh random key instead of
  # the signatures themselves leaves the comparison nothing useful to leak.
  k=$(openssl rand -hex 16)
  [[ $(printf %s "$expected" | hmac_hex "$k") == "$(printf %s "${BASH_REMATCH[2]}" | hmac_hex "$k")" ]]
}
 
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
  if verify "$1" "$2" "$3" "$4" "$5" "${6:-$(date +%s)}"; then echo true; else echo false; fi
fi

Your own script. Source the file (. verify.sh) and call verify SECRET SIGNATURE EVENT_ID METHOD URL NOW with the request body on standard input. It returns status 0 when the request is genuine.

Your secret and rotation

Each endpoint has a signing secret whsec_<64 hex> (32 random bytes) that ForestVPN generates. It is shown once, when you create the endpoint and again at each rotation; the dashboard never shows it afterwards, so store it when you see it. Keep it in your server's configuration or secret store, not in your source.

Rotation takes effect at once, with no overlap. The moment you rotate, requests are signed with the new secret and the old one stops matching. A request that reaches you between the rotation and your deploying the new secret fails your verification. Answer it with a 4xx or let it fail, and replay it from the dashboard afterwards. Deliveries are rebuilt and re-signed at every attempt, so a replay uses the new secret.

What your server answers

We read the status code only. The response body is never read and never stored, and a redirect is never followed.

Your answerResult
2xxdelivered.
timeout (your endpoint's timeout, 1000 to 10000 ms, default 5000), network error, 408, 425, 429, any 5xxretrying, see below.
3xxfailed (redirect). Not followed: give us the final URL in the template.
any other 4xxfailed (http). Not retried.
a URL that fails the safety rules at send timefailed (url).

Retries wait 60 s, 5 min, 30 min, 2 h, 6 h, then 12 h, 12 h and 12 h: nine attempts in about 45 hours. After the ninth failure the delivery is dead. The delivery log in the dashboard shows, for each delivery, its status, attempts, last HTTP status, last error word (timeout, network, redirect, http or url) and the time of the last attempt, and keeps them for 90 days.

Verify, record the event and answer 2xx quickly; do slow work afterwards, not before you answer. Answer a request that fails verification with 401: a 4xx is not retried.

A dead, failed or even delivered delivery can be replayed from the dashboard: it goes back to queued, the attempt count restarts, and you receive the same event_id with a fresh t and a fresh signature. Duplicates are therefore normal; that is what event_id is for.

Test your endpoint

Send test in the dashboard sends one synthetic request now, signed like a real one, and shows {http, ms} or the error word. The request has event test, event_id test.<16 hex>, conversion_id 32 zeros, amount and commission 0.00, currency USD, rail empty and ts now. It is not stored, creates no delivery and counts nothing. It works on an endpoint you have switched off. Make sure your handler verifies it and answers 2xx without counting it as a sale.

Run your verifier over the test vectors first, then Send test against your real URL, then replay a delivery from the log if you want to see a real event arrive again.

URL rules

Checked when you save the template and again at every send:

  • https only; no user name or password; no #fragment; port absent (an explicit :443 is refused).
  • The host is a DNS name of at least two labels: not an IP address (v4 or v6), not localhost, not ending in .local, .localhost, .internal, .arpa, .test or .invalid, and not fvpn.net, gbyte.tech or workers.dev or any subdomain of them.
  • The expanded URL is at most 4096 bytes (the template at most 2048 characters).
  • We set no header except the ones listed above (the network adds its own transport headers), send no cookies and follow no redirect.

Support

Write to support@fvpn.net and quote the event_id. We never give you, and you should never ask a customer for, an account number.

Back to Help

Back to top

Ask us anything