Signed return URLs
After a payment, GIEP sends the shopper back to your return page with giep_* query parameters
(SELL-047). With a store signing secret (Stores & integrations > Integrations, shown once when generated) the
URL is signed:
| Parameter | Meaning |
|---|---|
giep_session |
checkout session id |
giep_status |
paid, failed or cancelled |
giep_final |
1 final (paid, cancelled), 0 not final (failed: the shopper may still pay) |
giep_order |
GIEP order id (paid only) |
giep_amount, giep_currency |
total in minor units, ISO 4217 |
giep_ref |
your order reference, when you sent one |
giep_ts |
unix seconds when the URL was built |
giep_sig |
hex HMAC-SHA256 with your current secret |
giep_sig_prev |
the same with your previous secret, only for 24 h after a rotation |
Algorithm: take every giep_* parameter except giep_sig and giep_sig_prev, sort by name,
join as name=value with & where each value is percent-encoded like JavaScript's
encodeURIComponent, HMAC-SHA256 it with your secret, and compare in constant time with giep_sig
or giep_sig_prev. Reject a giep_ts more than 5 minutes away. The redirect is a pointer, never
proof of payment: fulfil only on the order.paid webhook or GET /v1/checkout-sessions/:id.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyGiepReturn(url, secret, maxAgeSeconds = 300) {
const q = new URL(url).searchParams;
const params = [...q].filter(([k]) => k.startsWith('giep_') && k !== 'giep_sig' && k !== 'giep_sig_prev');
const canonical = params
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join('&');
const expected = createHmac('sha256', secret).update(canonical).digest();
const ok = [q.get('giep_sig'), q.get('giep_sig_prev')].some((sig) => {
if (!sig || !/^[0-9a-f]{64}$/.test(sig)) return false;
const given = Buffer.from(sig, 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
});
const ts = Number(q.get('giep_ts'));
return ok && Number.isSafeInteger(ts) && Math.abs(Date.now() / 1000 - ts) <= maxAgeSeconds;
}PHP
function giep_verify_return(string $url, string $secret, int $maxAge = 300): bool {
parse_str((string) parse_url($url, PHP_URL_QUERY), $q);
$params = [];
foreach ($q as $k => $v) {
if (strpos($k, 'giep_') === 0 && $k !== 'giep_sig' && $k !== 'giep_sig_prev') $params[$k] = (string) $v;
}
ksort($params, SORT_STRING);
$parts = [];
// rawurlencode differs from encodeURIComponent only for !'()*, which GIEP values never contain.
foreach ($params as $k => $v) $parts[] = $k . '=' . rawurlencode($v);
$expected = hash_hmac('sha256', implode('&', $parts), $secret);
$ok = false;
foreach (['giep_sig', 'giep_sig_prev'] as $name) {
if (isset($q[$name]) && is_string($q[$name]) && hash_equals($expected, $q[$name])) $ok = true;
}
$ts = isset($q['giep_ts']) ? (int) $q['giep_ts'] : 0;
return $ok && abs(time() - $ts) <= $maxAge;
}Python
import hashlib, hmac, time
from urllib.parse import parse_qsl, quote, urlparse
def verify_giep_return(url: str, secret: str, max_age: int = 300) -> bool:
q = dict(parse_qsl(urlparse(url).query, keep_blank_values=True))
params = sorted((k, v) for k, v in q.items()
if k.startswith("giep_") and k not in ("giep_sig", "giep_sig_prev"))
# safe="-_.!~*'()" matches JavaScript's encodeURIComponent.
canonical = "&".join(f"{k}={quote(v, safe=\"-_.!~*'()\")}" for k, v in params)
expected = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()
ok = any(hmac.compare_digest(expected, q.get(n, "")) for n in ("giep_sig", "giep_sig_prev"))
ts = int(q.get("giep_ts", "0") or 0)
return ok and abs(time.time() - ts) <= max_ageRotation
Rotate in Stores & integrations > Integrations: the new secret signs at once (giep_sig) and the previous one keeps
signing (giep_sig_prev) for 24 hours. Update your shop to the new secret within that window; code
like the samples above, which accepts either parameter, sees no failed verification.