<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[ContaShare]]></title><description><![CDATA[ContaShare]]></description><link>https://contashare.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 14 Nov 2026 20:12:28 GMT</lastBuildDate><atom:link href="https://contashare.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Six decisions behind a webhook for personal data]]></title><description><![CDATA[Why our webhooks carry no personal data, why a change can wait an hour, and what else changes when the thing that changed is someone's address.]]></description><link>https://contashare.hashnode.dev/six-decisions-behind-a-webhook-for-personal-data</link><guid isPermaLink="true">https://contashare.hashnode.dev/six-decisions-behind-a-webhook-for-personal-data</guid><category><![CDATA[webhooks]]></category><category><![CDATA[api]]></category><category><![CDATA[Security]]></category><category><![CDATA[privacy]]></category><dc:creator><![CDATA[admincontashare]]></dc:creator><pubDate>Fri, 13 Nov 2026 07:40:01 GMT</pubDate><content:encoded><![CDATA[<p>Most webhook advice is about reliability: retries, signatures, idempotency. All of it applies. But when the thing that changed is someone's home address or email, there is a second set of questions, about what happens when the webhook, the log it lands in or the account behind it falls into the wrong hands.</p>
<p>I run <a href="https://contashare.com/businesses?utm_source=hashnode&amp;utm_medium=post">ContaShare</a>, where a person keeps their contact details in one account and chooses which companies can read them. When they change a detail, each company they share it with gets a webhook. Here are six decisions from building that, and what each one cost.</p>
<h2>1. The webhook carries no personal data</h2>
<p>This is the whole payload:</p>
<pre><code class="language-json">{
    "id": "e5aeb9bf-5676-41bc-9261-fc88920aaa56",
    "type": "address",
    "action": "update",
    "resource_url": "https://contashare.com/api/v1/address/e5aeb9bf-5676-41bc-9261-fc88920aaa56"
}
</code></pre>
<p>No address, no name, no customer ID. The receiver fetches <code>resource_url</code> with that customer's OAuth token to get the new version.</p>
<p>Putting the new address in the payload would save the receiver a request. But webhook bodies end up in places nobody audits: request logs, error trackers, a tunnelling tool's history, the retry queue of whatever sits in front of the app. A payload with nothing in it can be logged anywhere.</p>
<p>It also means permission is checked at the moment of reading, not the moment of sending. If the customer stops sharing between the webhook and the fetch, the fetch returns <code>404</code> and the company never sees the new address.</p>
<p><strong>The cost:</strong> the receiver has to store which customer each record <code>id</code> belongs to, and make a second request.</p>
<h2>2. Every webhook means "fetch the latest"</h2>
<p>A payload with values in it has an ordering problem. Two changes a minute apart, delivered out of order after a retry, leave the receiver holding the older one.</p>
<p>A payload that only says "this record changed" doesn't. Two webhooks for the same record are identical, handling one twice is harmless, and whichever arrives last still fetches the current version. Receivers can fold queued duplicates into one fetch, and I tell them to.</p>
<p><strong>The cost:</strong> you can't offer a history of changes through the webhook. For contact details nobody has asked for one.</p>
<h2>3. Each company gets its own ID for the same person</h2>
<p><code>GET /api/v1/me</code> returns the ID a company knows a customer by. It never changes for that company, and every other company gets a different one for the same person.</p>
<p>A single global user ID is easier to build and to debug. It is also a join key: any two companies holding it could match their customer lists against each other without asking anyone. Giving each company its own ID removes that option entirely.</p>
<p><strong>The cost:</strong> support is harder. When a company quotes an ID at me, it only means something alongside which company is asking.</p>
<h2>4. A change can wait an hour, on purpose</h2>
<p>This is the one that surprised me most while building it.</p>
<p>A service that updates someone's email at every company at once is a very good target. Many sites reset a password by emailing the address on file. So if someone gets into a ContaShare account and changes the email, every connected company would be told the attacker's address, and the attacker then asks each one for a password reset.</p>
<p>So a change to anything a company already holds waits an hour before it goes anywhere. The owner is emailed at once, at the old address too, with a link that cancels the change and locks the thief out. Until the hour is up there is no webhook and the API keeps returning the version the company already has.</p>
<p>A change confirmed with an established passkey or two-factor, or with a link emailed to the owner, skips the wait. "Established" matters: a second factor added in the last 72 hours doesn't count, or the thief would add their own and carry on.</p>
<p><strong>The cost:</strong> a real customer with no second factor waits an hour for their new address to arrive. I kept it short for that reason. It only needs to be long enough for the owner to see the warning.</p>
<h2>5. The receiver is told not to trust the change either</h2>
<p>The hold reduces the risk and doesn't remove it. So the docs say it plainly: treat an <code>update</code> as new contact details, not as proof of who someone is. Before a changed email or phone number is used to reset a password or send a login code, tell the old address and ask the customer to confirm in your own app.</p>
<p>There is a webhook for the worst case too. If an account is reported stolen, it is locked, and each company gets <code>locked</code> for the records it holds. Fetches return <code>423</code> until <code>unlocked</code> arrives. The advice on <code>locked</code> is to treat anything received for that customer in the last 7 days as possibly not from them.</p>
<p><strong>The cost:</strong> the integration guide is longer and less cheerful than a marketing page would like. I think that's the right way round.</p>
<h2>6. Stopping sharing sends nothing</h2>
<p>When a customer stops sharing a record with a company, that company gets no webhook. Its next fetch of the record returns <code>404</code>, the same as for a record that was deleted, and its token keeps working for anything else the customer still shares.</p>
<p>A "revoked" webhook would be tidier for the receiver. But it would announce a decision the customer may not want announced, and it can't be something a company's endpoint is able to delay or fail. Stopping sharing takes effect straight away, with nothing on the other side to wait for.</p>
<p><strong>The cost:</strong> receivers must handle <code>404</code> as "remove this", which the guide repeats in three places.</p>
<h2>The ordinary parts still matter</h2>
<p>For completeness, the reliability basics are all there:</p>
<ul>
<li>Every request has a <code>Signature</code> header: the HMAC-SHA256 of the raw body with the team's secret. Check it against the raw body, with a constant-time comparison.</li>
<li>A failed delivery is tried 3 times, 10 and then 100 seconds apart. After that it sits in a delivery history where the company can send it again once their endpoint is fixed.</li>
<li>Webhooks are free and so are failed API calls. A company pays for a successful read: 1.25p for an address.</li>
</ul>
<h2>What I'd tell someone building the same thing</h2>
<p>Ask what your webhook is worth to someone who shouldn't have it, and what your API does for someone who took over an account. For most data the answer is "not much", and a full payload is fine. For anything another site would use to decide who someone is, the answers shape the design more than the retry policy does.</p>
<p>If you want to see how it reads from the receiving side, the <a href="https://contashare.com/help/webhooks">webhooks guide</a> and <a href="https://contashare.com/docs/api">API reference</a> are public, and there are packages for <a href="https://github.com/contashare/contashare-socialite">Laravel</a>, <a href="https://github.com/contashare/contashare-node">Node</a> and <a href="https://github.com/contashare/contashare-python">Python</a>. New company accounts get 1,000 free credits with no card: <a href="https://contashare.com/businesses?utm_source=hashnode&amp;utm_medium=post">contashare.com/businesses</a>.</p>
<p>I'd welcome holes being picked in any of this, especially number 4.</p>
]]></content:encoded></item><item><title><![CDATA[Keep customer addresses current in Laravel with a sign-in button and a webhook]]></title><description><![CDATA[Add Sign in with ContaShare to a Laravel app with Socialite, then use a signed webhook to fetch a customer's new address when they change it.]]></description><link>https://contashare.hashnode.dev/keep-customer-addresses-current-in-laravel-with-a-sign-in-button-and-a-webhook</link><guid isPermaLink="true">https://contashare.hashnode.dev/keep-customer-addresses-current-in-laravel-with-a-sign-in-button-and-a-webhook</guid><category><![CDATA[Laravel]]></category><category><![CDATA[PHP]]></category><category><![CDATA[php8]]></category><category><![CDATA[webhooks]]></category><category><![CDATA[OAuth2]]></category><category><![CDATA[oauth]]></category><dc:creator><![CDATA[admincontashare]]></dc:creator><pubDate>Fri, 13 Nov 2026 07:38:12 GMT</pubDate><content:encoded><![CDATA[<p>Every app that stores a customer's address has the same problem: the address was right on the day it was typed. People move, and your app is a long way down the list of who they tell.</p>
<p>I built <a href="https://contashare.com/businesses?utm_source=hashnode&amp;utm_medium=post">ContaShare</a> to turn that round. A customer keeps their address, email, phone number and name in one account and ticks which of them your app may read. When they change one, your app gets a webhook and fetches the new version.</p>
<p>This post adds it to a Laravel app: an OAuth sign-in button with Socialite, then a webhook that keeps the address current. It takes about half an hour.</p>
<h2>What you need</h2>
<ul>
<li>A Laravel app with a <code>users</code> table.</li>
<li>A ContaShare company account. It comes with 1,000 free credits and needs no card.</li>
<li>An OAuth client, created under <strong>API Clients</strong> in that account, with your callback URL as its redirect URI.</li>
</ul>
<h2>Install the driver</h2>
<pre><code class="language-bash">composer require contashare/socialite
</code></pre>
<p>Add the client to <code>config/services.php</code>:</p>
<pre><code class="language-php">'contashare' =&gt; [
    'client_id' =&gt; env('CONTASHARE_CLIENT_ID'),
    'client_secret' =&gt; env('CONTASHARE_CLIENT_SECRET'),
    'redirect' =&gt; env('CONTASHARE_REDIRECT_URI'),
    'webhook_secret' =&gt; env('CONTASHARE_WEBHOOK_SECRET'),
],
</code></pre>
<p>And give your users somewhere to keep the connection:</p>
<pre><code class="language-php">Schema::table('users', function (Blueprint $table) {
    $table-&gt;string('contashare_id')-&gt;nullable()-&gt;unique();
    $table-&gt;text('contashare_token')-&gt;nullable();
    $table-&gt;text('contashare_refresh_token')-&gt;nullable();
});
</code></pre>
<p>Cast both tokens as <code>encrypted</code> on the model. They read a person's home address, so treat them like passwords.</p>
<h2>Sign up and sign in</h2>
<p>Two routes. The first sends the customer to ContaShare, where they see their records and tick the ones your app may read. The second handles them coming back.</p>
<pre><code class="language-php">use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Laravel\Socialite\Facades\Socialite;

Route::get('/auth/contashare', fn () =&gt; Socialite::driver('contashare')-&gt;redirect());

Route::get('/auth/contashare/callback', function () {
    $contashare = Socialite::driver('contashare');
    $customer = $contashare-&gt;user();

    $user = User::firstWhere('contashare_id', $customer-&gt;getId());

    if ($user === null) {
        // New customer: one call returns everything they shared.
        $shared = $contashare-&gt;sharedData($customer-&gt;token) ?? [];
        $email = collect($shared['emails'] ?? [])-&gt;sortByDesc('email_detail.is_verified')-&gt;first();
        $name = $shared['names'][0]['name_detail'] ?? null;

        abort_if($email === null, 422, 'Share an email address to sign up.');

        // Never sign someone in to an account that already has this email address.
        abort_if(User::where('email', $email['email_detail']['email_address'])-&gt;exists(), 409, 'Log in with your password instead.');

        $user = User::create([
            'contashare_id' =&gt; $customer-&gt;getId(),
            'name' =&gt; trim(($name['first_name'] ?? '').' '.($name['last_name'] ?? '')) ?: Str::before($email['email_detail']['email_address'], '@'),
            'email' =&gt; $email['email_detail']['email_address'],
            'password' =&gt; Str::random(40),
        ]);

        // Remember which records are this customer's. The webhook needs it.
        foreach ($shared['addresses'] ?? [] as $address) {
            $user-&gt;addresses()-&gt;create([
                'contashare_record_id' =&gt; $address['id'],
                'line_one' =&gt; trim($address['address_detail']['building_number'].' '.$address['address_detail']['address_line_one']),
                'city' =&gt; $address['address_detail']['city'],
                'postcode' =&gt; $address['address_detail']['postcode'],
            ]);
        }
    }

    $user-&gt;forceFill([
        'contashare_token' =&gt; $customer-&gt;token,
        'contashare_refresh_token' =&gt; $customer-&gt;refreshToken,
    ])-&gt;save();

    Auth::login($user);
    request()-&gt;session()-&gt;regenerate();

    return redirect('/dashboard');
});
</code></pre>
<p>Three things in there are worth a second look.</p>
<p><code>getId()</code> <strong>is your own ID for this person.</strong> It comes from <code>GET /api/v1/me</code>, which is free. It never changes, and another company gets a different ID for the same person, so two companies can't compare notes.</p>
<p><strong>The 409 matters.</strong> If someone already registered with that email, don't sign them in or link the accounts, even when the email is verified. Otherwise every account in your app, an admin's included, is only as safe as whichever ContaShare account holds that address. Ask them to log in the usual way and connect from their settings.</p>
<p><strong>Store the record</strong> <code>id</code><strong>s.</strong> The webhook in the next section tells you which record changed, not whose it is.</p>
<h2>Hear about changes</h2>
<p>Create a webhook under <strong>Webhooks</strong> in your ContaShare team settings, and put the secret it shows you in <code>CONTASHARE_WEBHOOK_SECRET</code>. It is shown once.</p>
<p>The webhook is small on purpose. It carries no personal data, only what changed and where to fetch it:</p>
<pre><code class="language-json">{
    "id": "e5aeb9bf-5676-41bc-9261-fc88920aaa56",
    "type": "address",
    "action": "update",
    "resource_url": "https://contashare.com/api/v1/address/e5aeb9bf-5676-41bc-9261-fc88920aaa56"
}
</code></pre>
<p>Check the signature against the raw body, queue the work and answer straight away:</p>
<pre><code class="language-php">use App\Jobs\SyncContaShareRecord;
use ContaShare\Socialite\WebhookSignature;
use Illuminate\Http\Request;

Route::post('/webhooks/contashare', function (Request $request) {
    abort_unless(WebhookSignature::isValid(
        $request-&gt;getContent(),
        $request-&gt;header('Signature'),
        config('services.contashare.webhook_secret'),
    ), 401);

    SyncContaShareRecord::dispatch($request-&gt;input('type'), $request-&gt;input('id'), $request-&gt;input('action'), $request-&gt;input('resource_url'));

    return response()-&gt;noContent();
});
</code></pre>
<p>Exempt that URL from CSRF checks in <code>bootstrap/app.php</code>. The signature does that job.</p>
<p>The job fetches the record with the customer's own token:</p>
<pre><code class="language-php">class SyncContaShareRecord implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
    use Queueable;

    public function __construct(public string $type, public string $id, public string $action, public string $resourceUrl) {}

    public function uniqueId(): string
    {
        return $this-&gt;type.':'.$this-&gt;id;
    }

    public function handle(): void
    {
        if ($this-&gt;type !== 'address') {
            return;
        }

        $address = Address::firstWhere('contashare_record_id', $this-&gt;id);

        if ($address === null) {
            return;
        }

        if ($this-&gt;action === 'delete') {
            $address-&gt;delete();

            return;
        }

        if ($this-&gt;action !== 'update') {
            return;
        }

        $response = Http::withToken($address-&gt;user-&gt;contashare_token)-&gt;acceptJson()-&gt;get($this-&gt;resourceUrl);

        if ($response-&gt;notFound()) {
            // Deleted, or the customer stopped sharing it.
            $address-&gt;delete();

            return;
        }

        $detail = $response-&gt;throw()-&gt;json('address_detail');

        $address-&gt;update([
            'line_one' =&gt; trim($detail['building_number'].' '.$detail['address_line_one']),
            'city' =&gt; $detail['city'],
            'postcode' =&gt; $detail['postcode'],
        ]);
    }
}
</code></pre>
<p><code>ShouldBeUniqueUntilProcessing</code> folds two webhooks for the same record into one fetch while the first is still queued. Every webhook means "fetch the latest", so handling one twice is harmless.</p>
<p>Press <strong>Send test</strong> on the Webhooks page to try your endpoint. The test uses a random <code>id</code>, so your job finds no address and stops, which is what you want.</p>
<h2>Things to know before you ship</h2>
<ul>
<li><strong>A 404 on fetch is a delete.</strong> The customer removed the record or stopped sharing it with you. They can do that at any time, and stopping sharing sends no webhook of its own.</li>
<li><strong>Tokens last a year.</strong> Refresh well before then with <code>Socialite::driver('contashare')-&gt;refreshToken($refreshToken)</code> and store the new pair.</li>
<li><strong>A changed email is new contact details, not proof of identity.</strong> Don't let it reset a password in your app without telling the old address first.</li>
<li><strong>A change can take up to an hour to arrive.</strong> A change to something you already hold waits an hour unless the customer confirmed it with a passkey, two-factor or an emailed link. That gives the owner time to stop a change made from a stolen account.</li>
<li><strong>It covers customers who connect.</strong> It won't clean a list of people who haven't, and it isn't a postcode lookup. Keep the postcode lookup on your form and use this for the years afterwards.</li>
</ul>
<h2>What it costs</h2>
<p>You pay per successful API call with credits. The first call that returns everything a new customer shared is 1 credit (0.625p). Fetching an address is 2 credits (1.25p). <code>GET /api/v1/me</code>, webhooks and failed calls are free, and there is no monthly fee.</p>
<p>So an address costs you 1.25p when it changes, and nothing in the years it doesn't.</p>
<h2>Links</h2>
<ul>
<li><a href="https://github.com/contashare/contashare-socialite">The driver on GitHub</a>. There are <a href="https://github.com/contashare/contashare-node">Node</a> and <a href="https://github.com/contashare/contashare-python">Python</a> packages too.</li>
<li><a href="https://contashare.com/help/oauth">OAuth guide</a> and <a href="https://contashare.com/help/webhooks">webhooks guide</a></li>
<li><a href="https://contashare.com/docs/api">API reference</a></li>
<li><a href="https://contashare.com/businesses?utm_source=hashnode&amp;utm_medium=post">What it does for a business, with prices</a></li>
</ul>
<p>I'd like to hear what's missing. If you try it and something in the flow is awkward, tell me in the comments.</p>
]]></content:encoded></item></channel></rss>