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.
I built ContaShare 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.
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.
What you need
- A Laravel app with a
userstable. - A ContaShare company account. It comes with 1,000 free credits and needs no card.
- An OAuth client, created under API Clients in that account, with your callback URL as its redirect URI.
Install the driver
composer require contashare/socialite
Add the client to config/services.php:
'contashare' => [
'client_id' => env('CONTASHARE_CLIENT_ID'),
'client_secret' => env('CONTASHARE_CLIENT_SECRET'),
'redirect' => env('CONTASHARE_REDIRECT_URI'),
'webhook_secret' => env('CONTASHARE_WEBHOOK_SECRET'),
],
And give your users somewhere to keep the connection:
Schema::table('users', function (Blueprint $table) {
$table->string('contashare_id')->nullable()->unique();
$table->text('contashare_token')->nullable();
$table->text('contashare_refresh_token')->nullable();
});
Cast both tokens as encrypted on the model. They read a person's home address, so treat them like passwords.
Sign up and sign in
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.
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Laravel\Socialite\Facades\Socialite;
Route::get('/auth/contashare', fn () => Socialite::driver('contashare')->redirect());
Route::get('/auth/contashare/callback', function () {
$contashare = Socialite::driver('contashare');
$customer = $contashare->user();
$user = User::firstWhere('contashare_id', $customer->getId());
if ($user === null) {
// New customer: one call returns everything they shared.
$shared = $contashare->sharedData($customer->token) ?? [];
$email = collect($shared['emails'] ?? [])->sortByDesc('email_detail.is_verified')->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'])->exists(), 409, 'Log in with your password instead.');
$user = User::create([
'contashare_id' => $customer->getId(),
'name' => trim(($name['first_name'] ?? '').' '.($name['last_name'] ?? '')) ?: Str::before($email['email_detail']['email_address'], '@'),
'email' => $email['email_detail']['email_address'],
'password' => Str::random(40),
]);
// Remember which records are this customer's. The webhook needs it.
foreach ($shared['addresses'] ?? [] as $address) {
$user->addresses()->create([
'contashare_record_id' => $address['id'],
'line_one' => trim($address['address_detail']['building_number'].' '.$address['address_detail']['address_line_one']),
'city' => $address['address_detail']['city'],
'postcode' => $address['address_detail']['postcode'],
]);
}
}
$user->forceFill([
'contashare_token' => $customer->token,
'contashare_refresh_token' => $customer->refreshToken,
])->save();
Auth::login($user);
request()->session()->regenerate();
return redirect('/dashboard');
});
Three things in there are worth a second look.
getId() is your own ID for this person. It comes from GET /api/v1/me, which is free. It never changes, and another company gets a different ID for the same person, so two companies can't compare notes.
The 409 matters. 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.
Store the record ids. The webhook in the next section tells you which record changed, not whose it is.
Hear about changes
Create a webhook under Webhooks in your ContaShare team settings, and put the secret it shows you in CONTASHARE_WEBHOOK_SECRET. It is shown once.
The webhook is small on purpose. It carries no personal data, only what changed and where to fetch it:
{
"id": "e5aeb9bf-5676-41bc-9261-fc88920aaa56",
"type": "address",
"action": "update",
"resource_url": "https://contashare.com/api/v1/address/e5aeb9bf-5676-41bc-9261-fc88920aaa56"
}
Check the signature against the raw body, queue the work and answer straight away:
use App\Jobs\SyncContaShareRecord;
use ContaShare\Socialite\WebhookSignature;
use Illuminate\Http\Request;
Route::post('/webhooks/contashare', function (Request $request) {
abort_unless(WebhookSignature::isValid(
$request->getContent(),
$request->header('Signature'),
config('services.contashare.webhook_secret'),
), 401);
SyncContaShareRecord::dispatch($request->input('type'), $request->input('id'), $request->input('action'), $request->input('resource_url'));
return response()->noContent();
});
Exempt that URL from CSRF checks in bootstrap/app.php. The signature does that job.
The job fetches the record with the customer's own token:
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->type.':'.$this->id;
}
public function handle(): void
{
if ($this->type !== 'address') {
return;
}
$address = Address::firstWhere('contashare_record_id', $this->id);
if ($address === null) {
return;
}
if ($this->action === 'delete') {
$address->delete();
return;
}
if ($this->action !== 'update') {
return;
}
$response = Http::withToken($address->user->contashare_token)->acceptJson()->get($this->resourceUrl);
if ($response->notFound()) {
// Deleted, or the customer stopped sharing it.
$address->delete();
return;
}
$detail = $response->throw()->json('address_detail');
$address->update([
'line_one' => trim($detail['building_number'].' '.$detail['address_line_one']),
'city' => $detail['city'],
'postcode' => $detail['postcode'],
]);
}
}
ShouldBeUniqueUntilProcessing 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.
Press Send test on the Webhooks page to try your endpoint. The test uses a random id, so your job finds no address and stops, which is what you want.
Things to know before you ship
- A 404 on fetch is a delete. 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.
-
Tokens last a year. Refresh well before then with
Socialite::driver('contashare')->refreshToken($refreshToken)and store the new pair. - A changed email is new contact details, not proof of identity. Don't let it reset a password in your app without telling the old address first.
- A change can take up to an hour to arrive. 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.
- It covers customers who connect. 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.
What it costs
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). GET /api/v1/me, webhooks and failed calls are free, and there is no monthly fee.
So an address costs you 1.25p when it changes, and nothing in the years it doesn't.
Links
- The driver on GitHub. There are Node and Python packages too.
- OAuth guide and webhooks guide
- API reference
- What it does for a business, with prices
I'd like to hear what's missing. If you try it and something in the flow is awkward, tell me in the comments.
Top comments (0)