Skip to content

Instantly share code, notes, and snippets.

@atomjoy
Last active October 19, 2025 08:10
Show Gist options
  • Select an option

  • Save atomjoy/1ed5c501e1748f92ac3684e0fa0870cc to your computer and use it in GitHub Desktop.

Select an option

Save atomjoy/1ed5c501e1748f92ac3684e0fa0870cc to your computer and use it in GitHub Desktop.
Creating a Stripe notification header signature in PHP.

Stripe Header Signature

How to create a Stripe notification header signature in PHP and test it in Laravel.

Create Signature

Stripe signature: 't=timestamp,v1=signature,v0=none',

<?php
// Shop order uniquie id
$payment_id = uniqeid();

// Event notification from stripe events response
$notification = '{"id":"evt_3SJX9eRyateVgXPV1c4eKy","object":"event","api_version":"2025-06-30.basil","created":1760782482,"data":{"object":{"id":"pi_3SJX9eRyateVgXPV1vgDbONB","object":"payment_intent","amount":3300,"amount_capturable":0,"amount_details":{"shipping":{"amount":0,"from_postal_code":null,"to_postal_code":null},"tax":{"total_tax_amount":0},"tip":{}},"amount_received":3300,"application":null,"application_fee_amount":null,"automatic_payment_methods":null,"canceled_at":null,"cancellation_reason":null,"capture_method":"automatic_async","client_secret":"pi_3SJX9eRyateVgXPV1vgDbONB_secret_sKTuxnRXJyJWEfx3jihfCUhjH","confirmation_method":"automatic","created":1760782470,"currency":"pln","customer":null,"description":null,"excluded_payment_method_types":null,"last_payment_error":null,"latest_charge":"py_3SJX9eRyateVgXPV1Nn1tSbq","livemode":false,"metadata":{"last_name":"Max","message":"Hello test","currency":"PLN","payment_id":"' . $payment_id . '","ip":"127.43.67.146","email":"[email protected]","amount":"3300","name":"Max"},"next_action":null,"on_behalf_of":null,"payment_details":{"customer_reference":null,"order_reference":"prod_TG3FFZ6CTYkpNn"},"payment_method":"pm_1SJX9eRyateVgXPVY4YgaaYd","payment_method_configuration_details":null,"payment_method_options":{"blik":{}},"payment_method_types":["blik"],"processing":null,"receipt_email":null,"review":null,"setup_future_usage":null,"shipping":null,"source":null,"statement_descriptor":null,"statement_descriptor_suffix":null,"status":"succeeded","transfer_data":null,"transfer_group":null}},"livemode":false,"pending_webhooks":1,"request":{"id":null,"idempotency_key":null},"type":"payment_intent.succeeded"}';

// Check
if (!json_validate($notification)) {
  throw new Exception("Notify invalid json");
}

// Format (required)
$notification = json_encode(json_decode($notification, true));

// Time
$time = time();

// Payload
$payload = "{$time}.{$notification}";

// APi scheme
$scheme = 'v1';

// Signature see in: \Stripe\WebhookSignature::computeSignature($payload, env('STRIPE_WEBHOOK_SECRET'));
$signature = \hash_hmac('sha256', $payload, env('STRIPE_WEBHOOK_SECRET'));

// Notification signature and stripe notification server ip (for test)
$response = $this->postJson(
  '/api/notify/stripe/donate',
  json_decode($notification, true),
  [
    'REMOTE_ADDR' => '3.18.12.63',
    'Stripe-Signature' => 't=' . $time . ',' . $scheme . '=' . $signature . ',v0=none',
  ]
);
<?php
namespace App\Enums\Payments;
enum PaymentStatusEnum: string
{
case NEW = 'new';
case PENDING = 'pending';
case WAITING = 'waiting';
case COMPLETED = 'completed';
case CANCELED = 'canceled';
case REFUNDED = 'refunded';
case REJECTED = 'rejected';
case FAILED = 'failed';
public static function toName($value): string
{
return self::from($value)->name;
}
// public static function getByName($name)
// {
// return match ($name) {
// 'completed' => self::COMPLETED,
// };
// }
}
<?php
namespace App\Payments\Actions;
use Exception;
use Throwable;
use App\Models\Donation;
use App\Enums\Payments\PaymentStatusEnum;
use App\Http\Requests\StoreDonationRequest;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
// Dizła trzeba tylko utworzyć klasę modelu, kontrolera i migracji (php artisan make:model Donation -arR)
// oraz utworzyć weebhook stripe w panelu lub z route uruchom PayStripe::createWebhook()
// po dodaniu kluczy do .env STRIPE_SECRET, STRIPE_WEBHOOK_SECRET.
class PayStripe
{
// Customer page after successfull payment
const SUCCESS_URL = '/donate/success?csid={CHECKOUT_SESSION_ID}';
// Stripe webhook notify
const NOTIFY_URL = '/api/notify/stripe/donate';
// Api locale
const AUTH_COUNTRY = 'pl';
public $currency_codes = ['PLN', 'USD', 'EUR'];
public $webhook_ips = [
'3.18.12.63',
'3.130.192.231',
'13.235.14.237',
'13.235.122.149',
'18.211.135.69',
'35.154.171.200',
'52.15.183.38',
'54.88.130.119',
'54.88.130.237',
'54.187.174.169',
'54.187.205.235',
'54.187.216.72',
];
public function paymentMethods()
{
return ['card', 'paypal', 'blik', 'p24'];
}
protected function secretKey(): string
{
if (empty(env('STRIPE_SECRET'))) {
throw new Exception("Empty stripe secret", 422);
}
return env('STRIPE_SECRET');
}
protected function webhookKey(): string
{
if (empty(env('STRIPE_WEBHOOK_SECRET'))) {
throw new Exception("Empty stripe webhook secret", 422);
}
return env('STRIPE_WEBHOOK_SECRET');
}
protected function currencyName(): string
{
$currency = env('STRIPE_CURRENCY', 'PLN');
return in_array($currency, $this->currency_codes) ? strtolower($currency) : 'PLN';
}
protected function allowedIps(): array
{
return $this->webhook_ips;
}
protected function logInSandbox(): bool
{
return str_contains($this->secretKey(), '_test_');
}
/**
* Get payment and refund notifications from payu.
* (Controller method)
*
* @return Response Return http response with status 200 or 422.
*/
public function weebhook()
{
try {
if (!in_array(request()->ip(), $this->allowedIps())) {
throw new Exception('Notify invalid ip address', 422);
}
$stripe = new \Stripe\StripeClient($this->secretKey());
$sig_header = request()->header('Stripe-Signature'); // Header Stripe-Signature
$payload = request()->getContent();
$event = null;
// (Errors 400) In basil api works in clover api remove it
// $payload = $this->jsonEncode(json_decode($payload));
if ($this->logInSandbox()) {
Log::info('STRIPE_NOTIFY', [
'timestamp' => time(),
'signature' => $sig_header,
'payload' => $payload,
]);
}
try {
$event = \Stripe\Webhook::constructEvent(
$payload,
$sig_header,
$this->webhookKey()
);
} catch (\UnexpectedValueException $e) {
return response()->json([
'message' => $e->getMessage()
], 400);
} catch (\Stripe\Exception\SignatureVerificationException $e) {
return response()->json([
'message' => $e->getMessage()
], 400);
}
$paymentIntent = null; // \Stripe\PaymentIntent
$paymentMethod = null; // \Stripe\PaymentMethod
$paymentLink = null;
$subscription = null;
$session = null;
// Handle the event
switch ($event->type) {
case 'checkout.session.async_payment_failed':
$session = $event->data->object;
case 'checkout.session.async_payment_succeeded':
$session = $event->data->object;
case 'checkout.session.completed':
$session = $event->data->object;
case 'checkout.session.expired':
$session = $event->data->object;
case 'payment_intent.amount_capturable_updated': // wiating_for_confirmation
$paymentIntent = $event->data->object;
case 'payment_intent.canceled': // canceled
$paymentIntent = $event->data->object;
case 'payment_intent.created': // new
$paymentIntent = $event->data->object;
case 'payment_intent.partially_funded': // pending
$paymentIntent = $event->data->object;
case 'payment_intent.payment_failed': // failed
$paymentIntent = $event->data->object;
case 'payment_intent.processing': // pending
$paymentIntent = $event->data->object;
case 'payment_intent.requires_action': // pending
$paymentIntent = $event->data->object;
case 'payment_intent.succeeded': // completed
$paymentIntent = $event->data->object;
case 'payment_link.created':
$paymentLink = $event->data->object;
case 'payment_link.updated':
$paymentLink = $event->data->object;
case 'refund.created':
$refund = $event->data->object;
case 'refund.updated':
$refund = $event->data->object;
case 'subscription_schedule.aborted':
$subscription = $event->data->object;
case 'subscription_schedule.canceled':
$subscription = $event->data->object;
case 'subscription_schedule.completed':
$subscription = $event->data->object;
case 'subscription_schedule.created':
$subscription = $event->data->object;
case 'subscription_schedule.expiring':
$subscription = $event->data->object;
case 'subscription_schedule.released':
$subscription = $event->data->object;
case 'subscription_schedule.updated':
$subscription = $event->data->object;
case 'payment_method.attached':
$paymentMethod = $event->data->object;
// ... handle other event types
default:
// Log to file
if ($this->logInSandbox()) {
Log::info('STRIPE_EVENT', [
'intent_status' => $paymentIntent->status ?? null,
$event->type => json_encode($event->data->object),
]);
}
// Handle successful payment here Intent: id, amount, currency, status
if ($paymentIntent instanceof \Stripe\PaymentIntent) {
if (empty($paymentIntent->metadata->payment_id)) {
throw new Exception("Invalid donation id");
}
$payment_id = $paymentIntent->metadata->payment_id;
$donation = Donation::where('payment_id', $payment_id)->first();
if ($donation instanceof Donation) {
// Get from stripe
$pi = $stripe->paymentIntents->retrieve($paymentIntent->id, []);
if ($pi instanceof \Stripe\PaymentIntent) {
if ($pi->status === 'succeeded') {
$donation->status = PaymentStatusEnum::COMPLETED->value;
$donation->external_id = $pi->id;
$donation->save();
}
if ($pi->status === 'canceled') {
$donation->status = PaymentStatusEnum::CANCELED->value;
$donation->external_id = $pi->id;
$donation->save();
}
if ($pi->status === 'requires_capture') {
$donation->status = PaymentStatusEnum::WAITING->value;
$donation->external_id = $pi->id;
$donation->save();
}
// This payment attempt failed once canceled on the bank's website.
// A subsequent bank payment may overwrite the status to **successful** if the
// customer hasn't left the strip payment page or if they open the payment link again (after resending),
// this status cannot be overwritten by **canceled**.
if ($pi->status === 'requires_payment_method' && isset($pi->last_payment_error)) {
$donation->status = PaymentStatusEnum::FAILED->value;
$donation->external_id = $pi->id;
$donation->save();
}
} else {
throw new Exception("Invalid intent id", 422);
}
} else {
throw new Exception("Invalid transaction id 👻👽🤡", 422);
}
}
}
return response()->json([
'message' => 'Comfirmed'
], 200);
} catch (Throwable $e) {
report($e);
return response()->json([
'message' => 'Not comfirmed'
], 422);
}
}
/**
* Encode json for payu
*
* @param array $arr
* @return string Json dtring
*/
public function jsonEncode($arr): string
{
return json_encode($arr, flags: JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
/**
* Decode json for payu
*
* @param string $json
* @return object
*/
public function jsonDecode($json): object
{
return json_decode($json, flags: JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
/**
* Change decimal to int amount
*
* @param float $decimal
* @return integer
*/
function toCents(float $decimal): int
{
return number_format($decimal * 100, 0, '.', '');
}
/**
* Change int amount to decimal
*
* @param int $amount
* @return float
*/
public function toDecimal($amount)
{
return number_format(($amount / 100), 2, '.', '');
}
/**
* Create payment order array
*
* @param Donation $donation
* @return array
*/
public function createOrder(Donation $donation): array
{
// Order
$order = [
// Payment methods
'payment_method_types' => $this->paymentMethods(),
// Custom price
'line_items' => [[
'price_data' => [
'unit_amount' => $donation->amount,
'currency' => $this->currencyName(),
'product_data' => [
'name' => 'Donate',
],
],
'quantity' => 1,
]],
// Details
'payment_intent_data' => [
'capture_method' => 'automatic_async',
'metadata' => [
'payment_id' => $donation->payment_id,
'email' => $donation->email,
'name' => $donation->name,
'last_name' => $donation->last_name ?? $donation->name,
'amount' => $donation->amount,
'message' => $donation->message,
'currency' => $this->currencyName(),
'ip' => request()->ip(),
],
],
// Redirect
'after_completion' => [
'type' => 'redirect',
'redirect' => [
'url' => request()->getSchemeAndHttpHost() . self::SUCCESS_URL
]
],
];
return $order;
}
/**
* Create checkout session payment orders array
*
* @param Donation $donation
* @return array
*/
public function createOrderCheckout(Donation $donation): array
{
// For checkout
// 'locked_prefilled_email' => $donation->email,
// 'prefilled_email' => $donation->email,
// 'prefilled_promo_code' => 'ABX123',
// 'locale' => 'pl',
// Order checkout
$order = [
// Payment methods
'customer_email' => $donation->email,
'locale' => strtolower(self::AUTH_COUNTRY),
'payment_method_types' => $this->paymentMethods(),
'mode' => 'payment',
// Custom price
'line_items' => [
[
'price_data' => [
// 'tax_behavior' => 'exclusive',
// 'recurring' => ['interval' => 'month', 'interval_count' => 3],
'unit_amount' => $donation->amount,
'currency' => $this->currencyName(),
'product_data' => [
'name' => 'Donate',
'images' => [
request()->getSchemeAndHttpHost() . '/default/donate/logo.png',
],
'unit_label' => 'szt.',
],
],
'quantity' => 1,
]
],
// Details
'payment_intent_data' => [
'metadata' => [
'payment_id' => $donation->payment_id,
'email' => $donation->email,
'name' => $donation->name,
'last_name' => $donation->last_name ?? $donation->name,
'amount' => $donation->amount,
'message' => $donation->message,
'currency' => $this->currencyName(),
'ip' => request()->ip(),
],
],
'metadata' => [
'payment_id' => $donation->payment_id,
'email' => $donation->email,
'name' => $donation->name,
'last_name' => $donation->last_name ?? $donation->name,
'amount' => $donation->amount,
'message' => $donation->message,
'currency' => $this->currencyName(),
'ip' => request()->ip(),
],
// Redirect
'success_url' => request()->getSchemeAndHttpHost() . self::SUCCESS_URL,
'cancel_url' => request()->getSchemeAndHttpHost() . self::SUCCESS_URL . '&error=501',
'branding_settings' => [
// 'button_color' => '#55cc55',
// 'display_name' => 'Welcome',
// 'logo' => [
// 'type' => 'url',
// 'url' => request()->getSchemeAndHttpHost() . '/default/donate/logo.png',
// ]
],
// Tax (Paid ble !!!)
// 'automatic_tax' => ['enabled' => true],
// Shipping (Paid ble !!!)
// 'shipping_address_collection' => ['allowed_countries' => ['PL', 'US', 'CA']],
// 'shipping_address_collection' => ['allowed_countries' => ['PL']],
// 'shipping_options' => [
// [
// 'shipping_rate_data' => [
// 'type' => 'fixed_amount',
// 'fixed_amount' => [
// 'amount' => 0,
// 'currency' => $this->currencyName(),
// ],
// 'display_name' => 'Online delivery',
// 'delivery_estimate' => [
// 'minimum' => [
// 'unit' => 'business_day',
// 'value' => 1,
// ],
// 'maximum' => [
// 'unit' => 'business_day',
// 'value' => 1,
// ],
// ],
// ],
// ],
// [
// 'shipping_rate_data' => [
// // 'tax_behavior' => 'exclusive',
// // 'tax_code' => 'txcd_92010001',
// 'type' => 'fixed_amount',
// 'fixed_amount' => [
// 'amount' => 1500,
// 'currency' => $this->currencyName(),
// ],
// 'display_name' => 'Next day air',
// 'delivery_estimate' => [
// 'minimum' => [
// 'unit' => 'business_day',
// 'value' => 1,
// ],
// 'maximum' => [
// 'unit' => 'business_day',
// 'value' => 1,
// ],
// ],
// ],
// ],
// ],
];
return $order;
}
/**
* Create payment link for donation with BasicAuth
*
* @param Donation $donation
* @return array
*/
public function donateCheckout(Donation $donation): string|null
{
$stripe = new \Stripe\StripeClient($this->secretKey());
// Payment Link
// $paymentLink = $stripe->paymentLinks->create($this->createOrder($donation));
// Checkout session
$paymentLink = $stripe->checkout->sessions->create($this->createOrderCheckout($donation));
// Success 200 or 302
if ($paymentLink instanceof \Stripe\Checkout\Session) {
// Update
$donation->external_id = $paymentLink->id;
$donation->url = $paymentLink->url;
$donation->save();
// extOrderId, orderId, redirectUri
return $paymentLink->url;
} else {
return null;
}
}
public static function createWebhook()
{
\Stripe\Stripe::setApiKey(env('STRIPE_SECRET'));
$endpoint = \Stripe\WebhookEndpoint::create([
'url' => request()->getSchemeAndHttpHost() . self::NOTIFY_URL,
'enabled_events' => [
'payment_link.created',
'payment_link.updated',
'payment_intent.created',
'payment_intent.canceled',
'payment_intent.succeeded',
'payment_intent.processing',
'payment_intent.payment_failed',
'payment_intent.requires_action',
'payment_intent.partially_funded',
'payment_intent.amount_capturable_updated',
'checkout.session.async_payment_failed',
'checkout.session.async_payment_succeeded',
'checkout.session.completed',
'checkout.session.expired',
],
]);
return $endpoint;
}
}
<?php
use App\Enums\Payments\PaymentGatewaysEnum;
use App\Enums\Payments\PaymentStatusEnum;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('donations', function (Blueprint $table) {
$table->id();
$table->string('name'); // Client name
$table->string('email'); // Client email
$table->string('message'); // Client message
$table->string('phone')->nullable(); // Client phone
$table->string('last_name')->nullable(); // Client last name
$table->unsignedBigInteger('gif')->nullable(); // Client gif image id
$table->unsignedBigInteger('amount')->nullable()->default(0);
$table->string('currency', 3)->nullable()->default('PLN');
$table->string('payment_id')->unique(); // Transaction id must be unique
$table->string('external_id')->nullable()->unique(); // Payment gateway transaction id
$table->enum('gateway', [...PaymentGatewaysEnum::cases()])->nullable()->default(PaymentGatewaysEnum::STRIPE->value);
$table->enum('status', [...PaymentStatusEnum::cases()])->nullable()->default(PaymentStatusEnum::NEW->value);
$table->unsignedTinyInteger('is_seen')->nullable()->default(0);
$table->text('url')->nullable();
$table->string('ip')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('donations');
}
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment