monsieursloop/woleet-bundle
A lightweight PHP/Laravel bundle for Woleet's Bitcoin blockchain timestamping API. Anchor document hashes, retrieve and verify proofs, and manage timestamp records through configurable service bindings for quick integration.
This release adds two configuration values that define the package's whole integration surface: API_TOKEN for authenticating outbound calls, and CALLBACK_SECRET for verifying inbound webhook callbacks. Treat them as a pair — you almost always need both before anything useful happens.
Publish the config file if you haven't already:
php artisan vendor:publish --tag=config
Add both values to .env (and to .env.example, so the next developer isn't guessing):
PACKAGE_API_TOKEN=your-issued-token
PACKAGE_CALLBACK_SECRET=your-signing-secret
Confirm they're being read through config(), not env(), directly:
// config/package.php
'api_token' => env('PACKAGE_API_TOKEN'),
'callback_secret' => env('PACKAGE_CALLBACK_SECRET'),
Clear the config cache and verify values resolve:
php artisan config:clear && php artisan tinker
>>> config('package.api_token'); // non-null
First use case: wire up the callback endpoint. Register a route, exclude it from CSRF (api middleware group or VerifyCsrfToken::$except), and have it validate the CALLBACK_SECRET signature before dispatching a job. That gives you an end-to-end loop: outbound authenticated request → provider processes → signed callback → verified job. Once that round-trip works in staging, everything else is variation.
Where to look first: the package's config file and any src/Http/ or src/Webhooks/ directory. The two env vars tell you exactly where the trust boundaries are — outbound credential and inbound signature — so start reading there.
Bind the credentials once, inject everywhere. Don't scatter config() calls through controllers. Resolve a single service from the container:
final class ApiClient
{
public function __construct(
private readonly string $token,
private readonly string $secret,
) {}
public static function fromConfig(): self
{
return new self(
token: config('package.api_token'),
secret: config('package.callback_secret'),
);
}
public function send(array $payload): Response
{
return Http::withToken($this->token)
->acceptJson()
->post(config('package.base_url'), $payload);
}
}
Register it as a singleton in a service provider so a rotated config in tests takes effect per-container, not per-process.
Outbound: centralise the token. Use Http::withToken() on a shared macro or a PendingRequest factory rather than adding Authorization headers by hand. This makes it trivial to swap in a retry, a timeout, or a ->withHeaders(['Idempotency-Key' => $uuid]) later.
Inbound: verify before you parse. Signature checks belong in middleware, upstream of the controller, and must run against the raw body:
final class VerifyCallbackSignature
{
public function handle(Request $request, Closure $next): Response
{
$expected = hash_hmac(
'sha256',
$request->getContent(),
config('package.callback_secret'),
);
$provided = (string) $request->header('X-Callback-Signature');
abort_unless(hash_equals($expected, $provided), 401);
return $next($request);
}
}
Apply it to the callback route only. If the controller reads $request->all() before this middleware runs, the body may already be re-encoded and the HMAC will never match.
Make the callback idempotent and fast. Webhook handlers should verify, persist the raw payload plus an external ID, dispatch a job, and return 200 immediately. Providers retry aggressively on timeouts; a slow handler looks like a failure and you'll get duplicate deliveries.
Route::post('/callbacks/package', function (Request $request) {
CallbackReceived::dispatch($request->all());
return response()->noContent();
})->middleware(VerifyCallbackSignature::class);
Queue the real work. Inside the job, use WithoutOverlapping keyed on the external ID to absorb the duplicate deliveries the middleware can't prevent. retryUntil() plus backoff() handles provider-side rate limits without hammering them.
Testing. Fake the outbound side and assert the token is attached:
Http::fake();
$this->postJson('/some-endpoint', $payload);
Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer '.config('package.api_token')));
For the inbound side, build a real signed request in a test helper rather than bypassing middleware — the signature check is the part most likely to break during a refactor:
$body = json_encode($payload);
$signature = hash_hmac('sha256', $body, config('package.callback_secret'));
$this->call('POST', '/callbacks/package', [], [], [], [
'HTTP_X_CALLBACK_SIGNATURE' => $signature,
'CONTENT_TYPE' => 'application/json',
], $body);
Environments. Keep separate tokens and secrets per environment, and never share the production CALLBACK_SECRET with staging. If the provider supports multiple active secrets, rotate by adding the new one, deploying, then retiring the old — rather than a hard cutover.
env() returns null after config:cache. The most common failure by far. env() outside of config files silently yields null in a cached deployment, so your token vanishes and requests go out unauthenticated. Always read through config('package.api_token'). If a production deploy "works locally but not on the server," check this first.
Queue workers cache the old secret. Workers are long-lived processes. Rotating CALLBACK_SECRET or API_TOKEN requires php artisan queue:restart, otherwise in-flight and future jobs keep signing with the previous value and callbacks start failing verification intermittently — the worst kind of bug to diagnose.
Use hash_equals, not ===. String comparison short-circuits and leaks timing information. hash_equals() is the only correct primitive here, and it's the single most likely thing to be stripped out during a "simplification" refactor.
Sign the raw body, not the parsed array. json_encode($request->all()) is not the same byte sequence the provider signed — key order, whitespace, and encoding all differ. Reach for $request->getContent(). If you must parse to route on a field, capture the raw body first.
JSON payloads and PHP's input parsing. For application/json, Laravel's getContent() still gives you the raw body, but custom middleware that instantiates Request differently can lose it. If signatures mysteriously fail only for JSON and pass for form-encoded, this is where to look.
CSRF and route placement. Callback routes must live outside the web middleware group or be explicitly excluded from VerifyCsrfToken. The symptom is a 419 on every delivery, which providers report as your endpoint being down.
Don't log the token or secret. Exception handlers love dumping request context. Add both keys to your log-scrubbing list, and be careful with Http::withToken() failures — the exception message can include headers in some configurations. Consider Log::withContext() scoping rather than dumping whole requests.
Timeouts and retries are not optional. Set ->timeout(10)->retry(3, 200) explicitly on outbound calls. Laravel's defaults are forgiving enough to hang a request worker for 30 seconds per attempt when a provider degrades, and you'll run out of PHP-FPM workers before you run out of retries.
Extension point: swap the client, not the config. If you need a sandbox mode, per-tenant tokens, or a token refreshed from a vault, bind an interface in the container and resolve credentials inside the client rather than at boot. Binding the token string directly into a singleton constructor means you can't rotate it without a redeploy.
Missing config should fail loudly. Add a guard so a null token doesn't produce a confusing 401 from the provider:
abort_if(blank(config('package.api_token')), 500, 'Package API token is not configured.');
Fail at the boundary with a clear message, not three layers down in an unrelated error.
Verify after upgrade. The version bump that introduced these two variables almost certainly renamed or replaced whatever credentials came before. Diff the published config against your local copy — vendor:publish --force will overwrite your customisations, so publish to a temp path, diff, then merge by hand.
How can I help you explore Laravel packages today?