├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── php.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── composer.json ├── config └── coinbase.php ├── database └── migrations │ └── create_coinbase_webhook_calls_table.php.stub ├── phpunit.xml.dist ├── src ├── Coinbase.php ├── CoinbaseServiceProvider.php ├── Exceptions │ └── WebhookFailed.php ├── Facades │ └── Coinbase.php ├── Http │ ├── Controllers │ │ └── WebhookController.php │ └── Middleware │ │ └── VerifySignature.php ├── Models │ └── CoinbaseWebhookCall.php └── Routes │ └── api.php └── tests ├── Http └── Middleware │ └── VerifySignatureTest.php └── TestCase.php /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ### Required Information 6 | 7 | - Operating system: 8 | - PHP version: 9 | - Laravel version: 10 | - Laravel Coinbase Commerce wrapper version: 11 | 12 | ### Expected behaviour 13 | 14 | 15 | ### Actual behaviour 16 | 17 | 18 | ### Steps to reproduce 19 | 20 | 21 | ### Extra details 22 | 23 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | Description of PR that completes issue here... 4 | 5 | ## Changes 6 | 7 | - Description of changes 8 | 9 | ## Checklist 10 | 11 | - [ ] Docs are complying/updated 12 | - [ ] Tests are passing 13 | 14 | Closes [TICKET-x](https://linktoticket.com/ticket-number-x) 15 | -------------------------------------------------------------------------------- /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: PHP Composer 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Validate composer.json and composer.lock 21 | run: composer validate --strict 22 | 23 | - name: Cache Composer packages 24 | id: composer-cache 25 | uses: actions/cache@v3 26 | with: 27 | path: vendor 28 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 29 | restore-keys: | 30 | ${{ runner.os }}-php- 31 | 32 | - name: Install dependencies 33 | run: composer install --prefer-dist --no-progress 34 | 35 | - name: Run test suite 36 | run: composer run-script test 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | composer.lock 3 | vendor 4 | phpunit.xml 5 | .phpunit.result.cache 6 | /.phpunit.cache -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. We accept contributions via Pull Requests on [Github](https://github.com/shakurov/laravel-coinbase/pulls). 4 | 5 | ## Pull Requests 6 | 7 | - **[PSR-12 Coding Standard.](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-12-extended-coding-style-guide.md)** The easiest way to apply the conventions is to install [PHP CS Fixer](https://github.com/FriendsOfPHP/PHP-CS-Fixer). 8 | - **Add tests!** Your patch won't be accepted if it doesn't have tests. 9 | - **Document any change in behaviour.** Make sure the `README.md` and any other relevant documentation are kept up-to-date. 10 | - **Consider our release cycle.** We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 11 | - **Create feature branches.** Don't ask us to pull from your master branch. 12 | - **One pull request per feature.** If you want to do more than one thing, send multiple pull requests. 13 | - **Send coherent history.** Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 14 | 15 | ## Running Tests 16 | 17 | First, you need to configure the environment variables. Copy the default phpunit config (`phpunit.xml.dist`) into "gitignored" `phpunit.xml` to avoid committing sensitive data: 18 | 19 | ```bash 20 | $ cp phpunit.xml.dist phpunit.xml 21 | ``` 22 | 23 | Edit `phpunit.xml` file, fill the `COINBASE_API_KEY` and `COINBASE_WEBHOOK_SECRET` env variables according to the data from your Coinbase Commerce account. 24 | 25 | Now run phpunit: 26 | ```bash 27 | $ composer run-script test 28 | ``` 29 | 30 | 31 | *Happy coding!* 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Vladimir Shakurov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel wrapper for the Coinbase Commerce API 2 | 3 | This package is abandoned and no longer maintained. The author suggests using the [antimech/coinbase](https://github.com/antimech/laravel-coinbase) package instead. 4 | 5 | ## Installation 6 | 7 | You can install the package via composer: 8 | 9 | ```bash 10 | composer require shakurov/coinbase 11 | ``` 12 | 13 | The service provider will automatically register itself. 14 | 15 | You must publish the config file with: 16 | ```bash 17 | php artisan vendor:publish --provider="Shakurov\Coinbase\CoinbaseServiceProvider" --tag="config" 18 | ``` 19 | 20 | This is the contents of the config file that will be published at `config/coinbase.php`: 21 | 22 | ```php 23 | return [ 24 | 'apiKey' => env('COINBASE_API_KEY'), 25 | 'apiVersion' => env('COINBASE_API_VERSION'), 26 | 27 | 'webhookSecret' => env('COINBASE_WEBHOOK_SECRET'), 28 | 'webhookJobs' => [ 29 | // 'charge:created' => \App\Jobs\CoinbaseWebhooks\HandleCreatedCharge::class, 30 | // 'charge:confirmed' => \App\Jobs\CoinbaseWebhooks\HandleConfirmedCharge::class, 31 | // 'charge:failed' => \App\Jobs\CoinbaseWebhooks\HandleFailedCharge::class, 32 | // 'charge:delayed' => \App\Jobs\CoinbaseWebhooks\HandleDelayedCharge::class, 33 | // 'charge:pending' => \App\Jobs\CoinbaseWebhooks\HandlePendingCharge::class, 34 | // 'charge:resolved' => \App\Jobs\CoinbaseWebhooks\HandleResolvedCharge::class, 35 | ], 36 | 'webhookModel' => Shakurov\Coinbase\Models\CoinbaseWebhookCall::class, 37 | ]; 38 | 39 | ``` 40 | 41 | In the `webhookSecret` key of the config file you should add a valid webhook secret. You can find the secret used at [the webhook configuration settings on the Coinbase Commerce dashboard](https://commerce.coinbase.com/dashboard/settings). 42 | 43 | Next, you must publish the migration with: 44 | ```bash 45 | php artisan vendor:publish --provider="Shakurov\Coinbase\CoinbaseServiceProvider" --tag="migrations" 46 | ``` 47 | 48 | After the migration has been published you can create the `coinbase_webhook_calls` table by running the migrations: 49 | 50 | ```bash 51 | php artisan migrate 52 | ``` 53 | 54 | Finally, take care of the routing: At [the Coinbase Commerce dashboard](https://commerce.coinbase.com/dashboard/settings) you must add a webhook endpoint, for example: `https://example.com/api/coinbase/webhook` 55 | 56 | ## Usage 57 | 58 | ### Charges 59 | 60 | List charges: 61 | ```php 62 | $charges = Coinbase::getCharges(); 63 | ``` 64 | 65 | Create a charge: 66 | ```php 67 | $charge = Coinbase::createCharge([ 68 | 'name' => 'Name', 69 | 'description' => 'Description', 70 | 'local_price' => [ 71 | 'amount' => 100, 72 | 'currency' => 'USD', 73 | ], 74 | 'pricing_type' => 'fixed_price', 75 | ]); 76 | ``` 77 | 78 | Show a charge: 79 | ```php 80 | $charge = Coinbase::getCharge($chargeId); 81 | ``` 82 | 83 | Cancel a charge: 84 | ```php 85 | $charge = Coinbase::cancelCharge($chargeId); 86 | ``` 87 | 88 | Resolve a charge: 89 | ```php 90 | $charge = Coinbase::resolveCharge($chargeId); 91 | ``` 92 | 93 | ### Checkouts 94 | 95 | List checkouts: 96 | ```php 97 | $checkouts = Coinbase::getCheckouts(); 98 | ``` 99 | 100 | Create a checkout: 101 | ```php 102 | $checkout = Coinbase::createCheckout([ 103 | 'name' => 'Name', 104 | 'description' => 'Description', 105 | 'requested_info' => [], 106 | 'local_price' => [ 107 | 'amount' => 100, 108 | 'currency' => 'USD', 109 | ], 110 | 'pricing_type' => 'fixed_price', 111 | ]); 112 | ``` 113 | 114 | Show a checkout: 115 | ```php 116 | $checkout = Coinbase::getCheckout($checkoutId); 117 | ``` 118 | 119 | Update a checkout: 120 | ```php 121 | $checkout = Coinbase::updateCheckout($checkoutId, [ 122 | 'name' => 'New Name', 123 | 'description' => 'New Description', 124 | 'local_price' => [ 125 | 'amount' => 200, 126 | 'currency' => 'USD', 127 | ], 128 | 'requested_info' => [ 129 | 'name', 130 | ], 131 | ]); 132 | ``` 133 | 134 | Delete a checkout: 135 | ```php 136 | $checkout = Coinbase::deleteCheckout($checkoutId); 137 | ``` 138 | 139 | ### Invoices 140 | 141 | List invoices: 142 | ```php 143 | $invoices = Coinbase::getInvoices(); 144 | ``` 145 | 146 | Create an invoice: 147 | ```php 148 | $invoice = Coinbase::createInvoice([ 149 | 'business_name' => 'Business Name', 150 | 'customer_email' => 'customer@example.com', 151 | 'customer_name' => 'Customer Name', 152 | 'local_price' => [ 153 | 'amount' => 100, 154 | 'currency' => 'USD', 155 | ], 156 | 'memo' => 'A memo/description for the invoice', 157 | ]); 158 | ``` 159 | 160 | Show an invoice: 161 | ```php 162 | $invoice = Coinbase::getInvoice($invoiceId); 163 | ``` 164 | 165 | Void an invoice: 166 | ```php 167 | $invoice = Coinbase::voidInvoice($invoiceId); 168 | ``` 169 | 170 | Resolve an invoice: 171 | ```php 172 | $invoice = Coinbase::resolveInvoice($invoiceId); 173 | ``` 174 | 175 | ### Events 176 | 177 | List events: 178 | ```php 179 | $events = Coinbase::getEvents(); 180 | ``` 181 | 182 | Show an event: 183 | ```php 184 | $event = Coinbase::getEvent($eventId); 185 | ``` 186 | 187 | ### Webhooks 188 | 189 | Coinbase Commerce will send out webhooks for several event types. You can find the [full list of events types](https://docs.cloud.coinbase.com/commerce/docs/webhooks-events#events) in the Coinbase Commerce documentation. 190 | 191 | Coinbase Commerce will sign all requests hitting the webhook url of your app. This package will automatically verify if the signature is valid. If it is not, the request was probably not sent by Coinbase Commerce. 192 | 193 | Unless something goes terribly wrong, this package will always respond with a `200` to webhook requests. Sending a `200` will prevent Coinbase Commerce from resending the same event over and over again. All webhook requests with a valid signature will be logged in the `coinbase_webhook_calls` table. The table has a `payload` column where the entire payload of the incoming webhook is saved. 194 | 195 | If the signature is not valid, the request will not be logged in the `coinbase_webhook_calls` table but a `Shakurov\Coinbase\Exceptions\WebhookFailed` exception will be thrown. 196 | If something goes wrong during the webhook request the thrown exception will be saved in the `exception` column. In that case the controller will send a `500` instead of `200`. 197 | 198 | There are two ways this package enables you to handle webhook requests: you can opt to queue a job or listen to the events the package will fire. 199 | 200 | 201 | ### Handling webhook requests using jobs 202 | If you want to do something when a specific event type comes in you can define a job that does the work. Here's an example of such a job: 203 | 204 | ```php 205 | webhookCall->payload` 228 | } 229 | } 230 | ``` 231 | 232 | We highly recommend that you make this job queueable, because this will minimize the response time of the webhook requests. This allows you to handle more Coinbase Commerce webhook requests and avoid timeouts. 233 | 234 | After having created your job you must register it at the `jobs` array in the `coinbase.php` config file. The key should be the name of [the coinbase commerce event type](https://commerce.coinbase.com/docs/api/#webhooks) where but with the `.` replaced by `_`. The value should be the fully qualified classname. 235 | 236 | ```php 237 | // config/coinbase.php 238 | 239 | 'jobs' => [ 240 | 'charge:created' => \App\Jobs\CoinbaseWebhooks\HandleCreatedCharge::class, 241 | ], 242 | ``` 243 | 244 | ### Handling webhook requests using events 245 | 246 | Instead of queueing jobs to perform some work when a webhook request comes in, you can opt to listen to the events this package will fire. Whenever a valid request hits your app, the package will fire a `coinbase::` event. 247 | 248 | The payload of the events will be the instance of `CoinbaseWebhookCall` that was created for the incoming request. 249 | 250 | Let's take a look at how you can listen for such an event. In the `EventServiceProvider` you can register listeners. 251 | 252 | ```php 253 | /** 254 | * The event listener mappings for the application. 255 | * 256 | * @var array 257 | */ 258 | protected $listen = [ 259 | 'coinbase::charge:created' => [ 260 | App\Listeners\ChargeCreatedListener::class, 261 | ], 262 | ]; 263 | ``` 264 | 265 | Here's an example of such a listener: 266 | 267 | ```php 268 | payload` 282 | } 283 | } 284 | ``` 285 | 286 | We highly recommend that you make the event listener queueable, as this will minimize the response time of the webhook requests. This allows you to handle more Coinbase Commerce webhook requests and avoid timeouts. 287 | 288 | The above example is only one way to handle events in Laravel. To learn the other options, read [the Laravel documentation on handling events](https://laravel.com/docs/10.x/events). 289 | 290 | ## Advanced usage 291 | 292 | ### Retry handling a webhook 293 | 294 | All incoming webhook requests are written to the database. This is incredibly valuable when something goes wrong while handling a webhook call. You can easily retry processing the webhook call, after you've investigated and fixed the cause of failure, like this: 295 | 296 | ```php 297 | use Shakurov\Coinbase\Models\CoinbaseWebhookCall; 298 | 299 | CoinbaseWebhookCall::find($id)->process(); 300 | ``` 301 | 302 | ### Performing custom logic 303 | 304 | You can add some custom logic that should be executed before and/or after the scheduling of the queued job by using your own model. You can do this by specifying your own model in the `model` key of the `coinbase` config file. The class should extend `Shakurov\Coinbase\Models\CoinbaseWebhookCall`. 305 | 306 | Here's an example: 307 | 308 | ```php 309 | use Shakurov\Coinbase\Models\CoinbaseWebhookCall; 310 | 311 | class MyCustomWebhookCall extends CoinbaseWebhookCall 312 | { 313 | public function process(): void 314 | { 315 | // do some custom stuff beforehand 316 | 317 | parent::process(); 318 | 319 | // do some custom stuff afterwards 320 | } 321 | } 322 | ``` 323 | 324 | ## License 325 | 326 | The MIT License (MIT). Please see [License File](LICENSE) for more information. 327 | 328 | 329 | ## Backers 330 | 331 | - [@antimech](https://github.com/antimech) 332 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shakurov/coinbase", 3 | "description": "Laravel wrapper for the Coinbase Commerce API", 4 | "keywords": [ 5 | "laravel", 6 | "coinbase", 7 | "coinbase commerce" 8 | ], 9 | "license": "MIT", 10 | "authors": [ 11 | { 12 | "name": "Vladimir Shakurov", 13 | "email": "vladimir@shakurov.com" 14 | } 15 | ], 16 | "require": { 17 | "php": "^8.1", 18 | "guzzlehttp/guzzle": "^7.0.1" 19 | }, 20 | "require-dev": { 21 | "phpunit/phpunit": "^10.2", 22 | "orchestra/testbench": "^8.5", 23 | "nunomaduro/collision": "^7.4" 24 | }, 25 | "autoload": { 26 | "psr-4": { 27 | "Shakurov\\Coinbase\\": "src/" 28 | } 29 | }, 30 | "autoload-dev": { 31 | "psr-4": { 32 | "Shakurov\\Coinbase\\Tests\\": "tests/" 33 | } 34 | }, 35 | "scripts": { 36 | "test": "vendor/bin/testbench package:test" 37 | }, 38 | "extra": { 39 | "laravel": { 40 | "providers": [ 41 | "Shakurov\\Coinbase\\CoinbaseServiceProvider" 42 | ], 43 | "aliases": { 44 | "Coinbase": "Shakurov\\Coinbase\\Facades\\Coinbase" 45 | } 46 | } 47 | }, 48 | "abandoned": "antimech/coinbase" 49 | } 50 | -------------------------------------------------------------------------------- /config/coinbase.php: -------------------------------------------------------------------------------- 1 | env('COINBASE_API_KEY'), 5 | 'apiVersion' => env('COINBASE_API_VERSION'), 6 | 7 | 'webhookSecret' => env('COINBASE_WEBHOOK_SECRET'), 8 | 'webhookJobs' => [ 9 | // 'charge:created' => \App\Jobs\CoinbaseWebhooks\HandleCreatedCharge::class, 10 | // 'charge:confirmed' => \App\Jobs\CoinbaseWebhooks\HandleConfirmedCharge::class, 11 | // 'charge:failed' => \App\Jobs\CoinbaseWebhooks\HandleFailedCharge::class, 12 | // 'charge:delayed' => \App\Jobs\CoinbaseWebhooks\HandleDelayedCharge::class, 13 | // 'charge:pending' => \App\Jobs\CoinbaseWebhooks\HandlePendingCharge::class, 14 | // 'charge:resolved' => \App\Jobs\CoinbaseWebhooks\HandleResolvedCharge::class, 15 | ], 16 | 'webhookModel' => Shakurov\Coinbase\Models\CoinbaseWebhookCall::class, 17 | ]; 18 | -------------------------------------------------------------------------------- /database/migrations/create_coinbase_webhook_calls_table.php.stub: -------------------------------------------------------------------------------- 1 | id(); 15 | $table->string('type')->nullable(); 16 | $table->text('payload')->nullable(); 17 | $table->text('exception')->nullable(); 18 | $table->timestamps(); 19 | }); 20 | } 21 | 22 | /** 23 | * Reverse the migrations. 24 | */ 25 | public function down(): void 26 | { 27 | Schema::dropIfExists('coinbase_webhook_calls'); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | tests 15 | 16 | 17 | 18 | 19 | 20 | src 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/Coinbase.php: -------------------------------------------------------------------------------- 1 | client = new Client([ 27 | 'base_uri' => Coinbase::BASE_URI, 28 | 'headers' => [ 29 | 'Content-Type' => 'application/json', 30 | 'X-CC-Api-Key' => $apiKey, 31 | 'X-CC-Version' => $apiVersion, 32 | ], 33 | ]); 34 | } 35 | 36 | /** 37 | * Make request. 38 | * 39 | * @param string $method 40 | * @param string $uri 41 | * @param array $query 42 | * @param array $params 43 | * @return array 44 | */ 45 | private function makeRequest(string $method, string $uri, array $query = [], array $params = []) 46 | { 47 | try { 48 | $response = $this->client->request($method, $uri, ['query' => $query, 'body' => json_encode($params)]); 49 | 50 | return json_decode((string) $response->getBody(), true); 51 | } catch(GuzzleException $e) { 52 | Log::error($e->getMessage()); 53 | } 54 | } 55 | 56 | /** 57 | * Lists all charges. 58 | * 59 | * @param array $query 60 | * @return array 61 | */ 62 | public function getCharges(array $query = []) 63 | { 64 | return $this->makeRequest('get', 'charges', $query); 65 | } 66 | 67 | /** 68 | * Creates a new charge. 69 | * 70 | * @param array $params 71 | * @return array 72 | */ 73 | public function createCharge(array $params = []) 74 | { 75 | return $this->makeRequest('post', 'charges', $params); 76 | } 77 | 78 | /** 79 | * Retrieves an existing charge by supplying its id or 8 character short-code. 80 | * 81 | * @param string $chargeId Id or short-code for a previously created charge 82 | * @return array 83 | */ 84 | public function getCharge(string $chargeId) 85 | { 86 | return $this->makeRequest('get', "charges/{$chargeId}"); 87 | } 88 | 89 | /** 90 | * Cancels an existing charge by supplying its id or 8 character short-code. 91 | * 92 | * Note: Only new charges can be successfully canceled. 93 | * 94 | * @param string $chargeId Id or short-code for a previously created charge 95 | * @return array 96 | */ 97 | public function cancelCharge(string $chargeId) 98 | { 99 | return $this->makeRequest('post', "charges/{$chargeId}/cancel"); 100 | } 101 | 102 | /** 103 | * Resolves an existing, unresolved charge by supplying its id or 8 character short-code. 104 | * 105 | * Note: Only unresolved charges can be successfully resolved. 106 | * 107 | * @param string $chargeId Id or short-code for a previously created charge 108 | * @return array 109 | */ 110 | public function resolveCharge(string $chargeId) 111 | { 112 | return $this->makeRequest('post', "charges/{$chargeId}/resolve"); 113 | } 114 | 115 | /** 116 | * Lists all checkouts. 117 | * 118 | * @param array $query 119 | * @return array 120 | */ 121 | public function getCheckouts(array $query = []) 122 | { 123 | return $this->makeRequest('get', 'checkouts', $query); 124 | } 125 | 126 | /** 127 | * Creates a new checkout. 128 | * 129 | * @param array $params 130 | * @return array 131 | */ 132 | public function createCheckout(array $params = []) 133 | { 134 | return $this->makeRequest('post', 'checkouts', [], $params); 135 | } 136 | 137 | /** 138 | * Retrieves an existing checkout. 139 | * 140 | * @param string $checkoutId 141 | * @return array 142 | */ 143 | public function getCheckout(string $checkoutId) 144 | { 145 | return $this->makeRequest('get', "checkouts/{$checkoutId}"); 146 | } 147 | 148 | /** 149 | * Updates an existing checkout. 150 | * 151 | * @param string $checkoutId 152 | * @param array $params 153 | * @return array 154 | */ 155 | public function updateCheckout(string $checkoutId, array $params = []) 156 | { 157 | return $this->makeRequest('put', "checkouts/{$checkoutId}", $params); 158 | } 159 | 160 | /** 161 | * Deletes an existing checkout. 162 | * 163 | * @param string $checkoutId 164 | * @return array 165 | */ 166 | public function deleteCheckout(string $checkoutId) 167 | { 168 | return $this->makeRequest('delete', "checkouts/{$checkoutId}"); 169 | } 170 | 171 | /** 172 | * Lists all invoices. 173 | * 174 | * @param array $query 175 | * @return array 176 | */ 177 | public function getInvoices(array $query = []) 178 | { 179 | return $this->makeRequest('get', 'invoices', $query); 180 | } 181 | 182 | /** 183 | * Creates a new invoice. 184 | * 185 | * @param array $params 186 | * @return array 187 | */ 188 | public function createInvoice(array $params = []) 189 | { 190 | return $this->makeRequest('post', 'invoices', $params); 191 | } 192 | 193 | /** 194 | * Retrieves an existing invoice by supplying its id or 8 character short-code. 195 | * 196 | * @param string $invoiceId Id or short-code for a previously created invoice 197 | * @return array 198 | */ 199 | public function getInvoice(string $invoiceId) 200 | { 201 | return $this->makeRequest('get', "invoices/{$invoiceId}"); 202 | } 203 | 204 | /** 205 | * Voids an existing invoice by supplying its id or 8 character short-code. 206 | * 207 | * Note: Only invoices with OPEN or VIEWED status can be voided. 208 | * 209 | * @param string $invoiceId Id or short-code for a previously created invoice 210 | * @return array 211 | */ 212 | public function voidInvoice(string $invoiceId) 213 | { 214 | return $this->makeRequest('post', "invoices/{$invoiceId}/void}"); 215 | } 216 | 217 | /** 218 | * Resolves an existing, unresolved invoice by supplying its id or 8 character short-code. 219 | * 220 | * Note: Only invoices with an unresolved charge can be successfully resolved. 221 | * 222 | * @param string $invoiceId Id or short-code for a previously created invoice 223 | * @return array 224 | */ 225 | public function resolveInvoice(string $invoiceId) 226 | { 227 | return $this->makeRequest('post', "invoices/{$invoiceId}/resolve}"); 228 | } 229 | 230 | /** 231 | * Lists all events. 232 | * 233 | * @param array $query 234 | * @return array 235 | */ 236 | public function getEvents(array $query = []) 237 | { 238 | return $this->makeRequest('get', 'events', $query); 239 | } 240 | 241 | /** 242 | * Retrieves an existing event. 243 | * 244 | * @param string $eventId 245 | * @return array 246 | */ 247 | public function getEvent(string $eventId) 248 | { 249 | return $this->makeRequest('get', "events/{$eventId}"); 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /src/CoinbaseServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 16 | __DIR__.'/../config/coinbase.php' => config_path('coinbase.php'), 17 | ], 'config'); 18 | 19 | $timestamp = '2018_06_01_215631'; 20 | 21 | $this->publishes([ 22 | __DIR__.'/../database/migrations/create_coinbase_webhook_calls_table.php.stub' => database_path("migrations/{$timestamp}_create_coinbase_webhook_calls_table.php"), 23 | ], 'migrations'); 24 | 25 | $this->loadRoutesFrom(__DIR__.'/Routes/api.php'); 26 | } 27 | 28 | /** 29 | * Register the application services. 30 | */ 31 | public function register(): void 32 | { 33 | $this->mergeConfigFrom( 34 | __DIR__.'/../config/coinbase.php', 'coinbase' 35 | ); 36 | 37 | $this->app->bind('coinbase', function ($app) { 38 | return new Coinbase($app); 39 | }); 40 | 41 | $this->app->alias('coinbase', Coinbase::class); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Exceptions/WebhookFailed.php: -------------------------------------------------------------------------------- 1 | id}` of type `{$webhookCall->type} because the configured jobclass `$jobClass` does not exist."); 30 | } 31 | 32 | public static function missingType(): static 33 | { 34 | return new static('The webhook call did not contain a type. Valid Coinbase Commerce webhook calls should always contain a type.'); 35 | } 36 | 37 | public function render($request): Response 38 | { 39 | return response(['error' => $this->getMessage()], 400); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Facades/Coinbase.php: -------------------------------------------------------------------------------- 1 | middleware(VerifySignature::class); 15 | } 16 | 17 | public function __invoke(Request $request): void 18 | { 19 | $payload = $request->input(); 20 | 21 | $model = config('coinbase.webhookModel'); 22 | 23 | $coinbaseWebhookCall = $model::create([ 24 | 'type' => $payload['event']['type'] ?? '', 25 | 'payload' => $payload, 26 | ]); 27 | 28 | try { 29 | $coinbaseWebhookCall->process(); 30 | } catch (\Exception $e) { 31 | $coinbaseWebhookCall->saveException($e); 32 | 33 | throw $e; 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /src/Http/Middleware/VerifySignature.php: -------------------------------------------------------------------------------- 1 | header('X-CC-Webhook-Signature'); 14 | 15 | if (! $signature) { 16 | throw WebhookFailed::missingSignature(); 17 | } 18 | 19 | if (! $this->isValid($signature, $request->getContent())) { 20 | throw WebhookFailed::invalidSignature($signature); 21 | } 22 | 23 | return $next($request); 24 | } 25 | 26 | protected function isValid(string $signature, string $payload): bool 27 | { 28 | $secret = config('coinbase.webhookSecret'); 29 | 30 | if (empty($secret)) { 31 | throw WebhookFailed::sharedSecretNotSet(); 32 | } 33 | 34 | $computedSignature = hash_hmac('sha256', $payload, $secret); 35 | 36 | return hash_equals($signature, $computedSignature); 37 | } 38 | } -------------------------------------------------------------------------------- /src/Models/CoinbaseWebhookCall.php: -------------------------------------------------------------------------------- 1 | 'array', 16 | 'exception' => 'array', 17 | ]; 18 | 19 | public function process(): void 20 | { 21 | $this->clearException(); 22 | 23 | if ($this->type === '') { 24 | throw WebhookFailed::missingType($this); 25 | } 26 | 27 | event("coinbase::{$this->type}", $this); 28 | 29 | $jobClass = $this->determineJobClass($this->type); 30 | 31 | if ($jobClass === '') { 32 | return; 33 | } 34 | 35 | if (! class_exists($jobClass)) { 36 | throw WebhookFailed::jobClassDoesNotExist($jobClass, $this); 37 | } 38 | 39 | dispatch(new $jobClass($this)); 40 | } 41 | 42 | public function saveException(Exception $exception): static 43 | { 44 | $this->exception = [ 45 | 'code' => $exception->getCode(), 46 | 'message' => $exception->getMessage(), 47 | 'trace' => $exception->getTraceAsString(), 48 | ]; 49 | 50 | $this->save(); 51 | 52 | return $this; 53 | } 54 | 55 | protected function determineJobClass(string $eventType): string 56 | { 57 | $jobConfigKey = str_replace('.', '_', $eventType); 58 | 59 | return config("coinbase.webhookJobs.{$jobConfigKey}", ''); 60 | } 61 | 62 | protected function clearException(): static 63 | { 64 | $this->exception = null; 65 | 66 | $this->save(); 67 | 68 | return $this; 69 | } 70 | } -------------------------------------------------------------------------------- /src/Routes/api.php: -------------------------------------------------------------------------------- 1 | 'api', 'middleware' => 'api'], function() { 4 | Route::post('coinbase/webhook', '\Shakurov\Coinbase\Http\Controllers\WebhookController')->name('coinbase-webhook'); 5 | }); -------------------------------------------------------------------------------- /tests/Http/Middleware/VerifySignatureTest.php: -------------------------------------------------------------------------------- 1 | middleware(VerifySignature::class); 20 | } 21 | 22 | /** @test */ 23 | public function it_will_succeed_when_the_request_has_a_valid_signature(): void 24 | { 25 | $payload = ['event' => ['type' => 'charge:created']]; 26 | 27 | $response = $this->postJson( 28 | 'coinbase-webhook', 29 | $payload, 30 | ['X-CC-Webhook-Signature' => $this->determineCoinbaseSignature($payload)] 31 | ); 32 | 33 | $response 34 | ->assertStatus(200) 35 | ->assertSee('ok'); 36 | } 37 | 38 | /** @test */ 39 | public function it_will_fail_when_the_signature_header_is_not_set(): void 40 | { 41 | $response = $this->postJson( 42 | 'coinbase-webhook', 43 | ['event' => ['type' => 'charge:created']] 44 | ); 45 | 46 | $response 47 | ->assertStatus(400) 48 | ->assertJson([ 49 | 'error' => 'The request did not contain a header named `X-CC-Webhook-Signature`.', 50 | ]); 51 | } 52 | 53 | /** @test */ 54 | public function it_will_fail_when_the_signature_is_invalid(): void 55 | { 56 | $response = $this->postJson( 57 | 'coinbase-webhook', 58 | ['event' => ['type' => 'charge:created']], 59 | ['X-CC-Webhook-Signature' => 'abc'] 60 | ); 61 | 62 | $response 63 | ->assertStatus(400) 64 | ->assertSee('found in the header named `X-CC-Webhook-Signature` is invalid'); 65 | } 66 | } -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 |