There's one last teeny, tiny little detail we need to worry about with webhooks:
replay attacks. These are a security concern but also a practical one.

We already know that nobody can send us, random, fake event data because we fetch
a fresh event from Stripe:

92 lines src/AppBundle/Controller/WebhookController.php

... lines 1 - 8

classWebhookControllerextendsBaseController

{

... lines 11 - 13

publicfunctionstripeWebhookAction(Request $request)

{

$data = json_decode($request->getContent(), true);

if ($data === null) {

thrownew \Exception('Bad JSON body from Stripe!');

}

$eventId = $data['id'];

if ($this->getParameter('verify_stripe_event')) {

$stripeEvent = $this->get('stripe_client')

->findEvent($eventId);

... lines 26 - 28

}

... lines 30 - 69

}

... lines 71 - 90

}

But, someone could intercept a real webhook, and send it to us multiple times.
I don't know why they would do that, but weird things would happen.

And there's also the practical concern. Suppose Stripe sends us a webhook and
we process it. But somehow, there was a connection problem between our server and
Stripe, so Stripe never received our 200 status code. Then, thinking that the webhook
failed, Stripe tries to send the webhook again. If this were for an invoice.payment_succeeded
event, one user might get two subscription renewal emails. That's weird.

Creating the stripe_event_log Table

Let's prevent that. And it's simple: create a database table that records
all the event ID's we've handled. Then, query that table before processing a
webhook to make sure we haven't seen it before.

In the AppBundle/Entity directory, create a new PHP Class called StripeEventLog:

36 lines src/AppBundle/Entity/StripeEventLog.php

... lines 1 - 2

namespaceAppBundle\Entity;

... lines 4 - 10

classStripeEventLog

{

... lines 13 - 34

}

Give it a few properties: $id, $stripeEventId and a $handledAt date field:

36 lines src/AppBundle/Entity/StripeEventLog.php

... lines 1 - 10

classStripeEventLog

{

... lines 13 - 17

private $id;

... lines 19 - 22

private $stripeEventId;

... lines 24 - 27

private $handledAt;

... lines 29 - 34

}

Since this project uses Doctrine, I'll add a special use statement on top and
then add some annotations, so that this new class will become a new table in the
database. Use the "Code"->"Generate" menu, or Command + N on a Mac and select "ORM Class":

36 lines src/AppBundle/Entity/StripeEventLog.php

... lines 1 - 4

useDoctrine\ORM\MappingasORM;

/**

* @ORM\Entity

* @ORM\Table(name="stripe_event_log")

*/

classStripeEventLog

{

... lines 13 - 34

}

Repeat that and select "ORM Annotations". Choose all the fields:

36 lines src/AppBundle/Entity/StripeEventLog.php

... lines 1 - 10

classStripeEventLog

{

/**

* @ORM\Id

* @ORM\GeneratedValue(strategy="AUTO")

* @ORM\Column(type="integer")

*/

private $id;

/**

* @ORM\Column(type="string", unique=true)

*/

private $stripeEventId;

/**

* @ORM\Column(type="datetime")

*/

private $handledAt;

... lines 29 - 34

}

Update stripeEventId to be a string field - that'll translate to a varchar in MySQL:

36 lines src/AppBundle/Entity/StripeEventLog.php

... lines 1 - 10

classStripeEventLog

{

... lines 13 - 19

/**

* @ORM\Column(type="string", unique=true)

*/

private $stripeEventId;

... lines 24 - 34

}

To set the properties, create a new __construct() method with a $stripeEventId
argument. Inside, set that on the property and also set $this->handledAt to a new
\DateTime() to set this field to "right now":

36 lines src/AppBundle/Entity/StripeEventLog.php

... lines 1 - 10

classStripeEventLog

{

... lines 13 - 29

publicfunction__construct($stripeEventId)

{

$this->stripeEventId = $stripeEventId;

$this->handledAt = new \DateTime();

}

}

Brilliant! And now that we have the entity class, find your terminal and run:

./bin/console doctrine:migrations:diff

This generates a new file in the app/DoctrineMigrations directory that contains
the raw SQL needed to create the new table:

35 lines app/DoctrineMigrations/Version20160807113428.php

... lines 1 - 2

namespaceApplication\Migrations;

useDoctrine\DBAL\Migrations\AbstractMigration;

useDoctrine\DBAL\Schema\Schema;

/**

* Auto-generated Migration: Please modify to your needs!

*/

classVersion20160807113428extendsAbstractMigration

{

/**

* @param Schema $schema

*/

publicfunctionup(Schema $schema)

{

// this up() migration is auto-generated, please modify it to your needs

$this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.');

// this down() migration is auto-generated, please modify it to your needs

$this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.');

$this->addSql('DROP TABLE stripe_event_log');

}

}

Execute that query by running:

./bin/console doctrine:migrations:migrate

Preventing the Replay Attack

Finally, in WebhookController, start by querying to see if this event has been
handled before. Fetch the EntityManager, and then add
$existingLog = $em->getRepository('AppBundle:StripeEventLog') and call
findOneBy() on it to query for stripeEventId set to $eventId.

104 lines src/AppBundle/Controller/WebhookController.php

... lines 1 - 9

classWebhookControllerextendsBaseController

{

... lines 12 - 14

publicfunctionstripeWebhookAction(Request $request)

{

... lines 17 - 21

$eventId = $data['id'];

$em = $this->getDoctrine()->getManager();

$existingLog = $em->getRepository('AppBundle:StripeEventLog')

->findOneBy(['stripeEventId' => $eventId]);

... lines 27 - 81

}

... lines 83 - 102

}

If an $existingLog is found, then we don't want to handle this. Just return a
new Response() that says "Event previously handled":

104 lines src/AppBundle/Controller/WebhookController.php

... lines 1 - 9

classWebhookControllerextendsBaseController

{

... lines 12 - 14

publicfunctionstripeWebhookAction(Request $request)

{

... lines 17 - 21

$eventId = $data['id'];

$em = $this->getDoctrine()->getManager();

$existingLog = $em->getRepository('AppBundle:StripeEventLog')

->findOneBy(['stripeEventId' => $eventId]);

if ($existingLog) {

returnnew Response('Event previously handled');

}

... lines 30 - 81

}

... lines 83 - 102

}

If you also want to log a message so that you know when this happens, that's not
a bad idea.

But if there is not an existing log, time to process this webhook! Create a new
StripeEventLog and pass it $eventId. Then, persist and flush just the log:

104 lines src/AppBundle/Controller/WebhookController.php

... lines 1 - 9

classWebhookControllerextendsBaseController

{

... lines 12 - 14

publicfunctionstripeWebhookAction(Request $request)

{

... lines 17 - 21

$eventId = $data['id'];

$em = $this->getDoctrine()->getManager();

$existingLog = $em->getRepository('AppBundle:StripeEventLog')

->findOneBy(['stripeEventId' => $eventId]);

if ($existingLog) {

returnnew Response('Event previously handled');

}

$log = new StripeEventLog($eventId);

$em->persist($log);

$em->flush($log);

... lines 34 - 81

}

... lines 83 - 102

}

And yea, replay attacks are gone!

Update the Test!

To make sure we didn't mess anything up, open WebhookControllerTest and copy our
test method. Run that:

./vendor/bin/phpunit --filter testStripeCustomerSubscriptionDeleted

Bah! Of course... it failed for a silly reason: I need to update my test database -
to add the new table. A shortcut to do that is:

./bin/console doctrine:schema:update --force --env=test

Try the test now:

./vendor/bin/phpunit --filter testStripeCustomerSubscriptionDeleted

It works! So hey, run it again!

./vendor/bin/phpunit --filter testStripeCustomerSubscriptionDeleted

It fails?!

Failed to assert that true is false.

Well, that's not clear, but I know what the problem is: every event in the test has
the same event ID:

128 lines tests/AppBundle/Controller/WebhookControllerTest.php

... lines 1 - 9

classWebhookControllerTestextendsWebTestCase

{

... lines 12 - 70

privatefunctiongetCustomerSubscriptionDeletedEvent($subscriptionId)

{

$json = <<<EOF

{

... lines 75 - 76

"id": "evt_00000000000000",

... lines 78 - 121

}

EOF;

... lines 124 - 125

}

}

So when you run the test the second time, this already exists in the StripeEventLog
table and the webhook is skipped. Well hey, at least we know the replay attack system
is working.

To fix this, we need to set a little bit of randomness to the event ID by adding
a %s at the end and adding an mt_rand() to the sprintf():

128 lines tests/AppBundle/Controller/WebhookControllerTest.php

... lines 1 - 9

classWebhookControllerTestextendsWebTestCase

{

... lines 12 - 70

privatefunctiongetCustomerSubscriptionDeletedEvent($subscriptionId)

{

$json = <<<EOF

{

... lines 75 - 76

"id": "evt_00000000000000%s",

... lines 78 - 121

}

EOF;

return sprintf($json, mt_rand(), $subscriptionId);

}

}

Now, every event ID will be unique. Try the test again:

./vendor/bin/phpunit --filter testStripeCustomerSubscriptionDeleted

Green and happy!

Ok, enough webhooks. Let's do something fun, like making it possible for a user
to upgrade from one subscription to another.