Difference between revisions of "Webhooks"
(→PHP) |
|||
(26 intermediate revisions by 2 users not shown) | |||
Line 1: | Line 1: | ||
− | New for 2018, our Webhook system is a beefed-up version of CasperVend's ANS notification system. This is an advanced system which requires programming knowledge. It's designed to reliably send events to | + | New for 2018, our Webhook system is a beefed-up version of CasperVend's ANS notification system. This is an advanced system which requires programming knowledge. It's designed to reliably send events to server(s) under your control. |
= <span style="color:#00528c">Benefits</span> = | = <span style="color:#00528c">Benefits</span> = | ||
− | : * | + | : * Increased reliability. If your server goes offline, we'll keep sending until it goes through (though see the caveats below) |
− | : * Not just for vendor sales, but can also notify for marketplace sales | + | : * Not just for vendor sales, but can also notify for marketplace sales and redeliveries |
+ | : * Not just for CasperVend, but will be rolled out for CasperLet, CasperSafe, CasperUpdate, etc. in the future. | ||
: * JSON encoded so can be read by nearly all backend frameworks | : * JSON encoded so can be read by nearly all backend frameworks | ||
: * In the event of a failure to reach your endpoint, we will notify your avatar by IM (after 5 attempts). | : * In the event of a failure to reach your endpoint, we will notify your avatar by IM (after 5 attempts). | ||
+ | : * SL Marketplace notifications include the matched CasperVend product ID (zero if not associated). | ||
+ | |||
+ | = <span style="color:#00528c">Events Currently Tracked</span> = | ||
+ | : * Product purchase inworld | ||
+ | : * SL Marketplace purchase | ||
+ | |||
+ | === Tracking Coming Soon For === | ||
+ | ---- | ||
+ | : * Redeliveries | ||
+ | : * Update Delivery | ||
+ | |||
+ | (This list will be added to and corrected as features and additional event tracking are built in.) | ||
= <span style="color:#00528c">Caveats</span> = | = <span style="color:#00528c">Caveats</span> = | ||
Line 13: | Line 26: | ||
: * Because of the retry mechanism, your code must be prepared to accept duplicate requests. | : * Because of the retry mechanism, your code must be prepared to accept duplicate requests. | ||
: * We send events in date order, so if an early event fails, you won't receive any later events until your endpoint responds correctly. | : * We send events in date order, so if an early event fails, you won't receive any later events until your endpoint responds correctly. | ||
− | : * Your endpoint must respond within | + | : * Your endpoint must respond within 3 seconds. |
+ | |||
+ | = <span style="color:#00528c">Getting Help</span> = | ||
+ | |||
+ | This is an advanced feature; you are expected to know how to write and debug your own scripts, so we won't help you do that. | ||
+ | |||
+ | However, if you need a different kind of help - or if you think something is wrong at our end, do a '''[[CasperVend_2/Getting_Help#How_Can_I_Get_Help.3F | ticket to Casper.]]''' The rest of the support staff are NOT scripters and CANNOT assist you with this. | ||
= <span style="color:#00528c">Example Code</span> = | = <span style="color:#00528c">Example Code</span> = | ||
Line 22: | Line 41: | ||
<syntaxhighlight lang="sql" line='line'> | <syntaxhighlight lang="sql" line='line'> | ||
− | CREATE DATABASE IF NOT EXISTS `webhooks` | + | CREATE DATABASE IF NOT EXISTS `webhooks`; |
USE `webhooks`; | USE `webhooks`; | ||
Line 47: | Line 66: | ||
define('ENABLE_DEBUG', true); | define('ENABLE_DEBUG', true); | ||
− | define('DEBUG_EMAIL', ' | + | define('DEBUG_EMAIL', 'your@email.address'); |
define('DATABASE_HOST', '127.0.0.1'); | define('DATABASE_HOST', '127.0.0.1'); | ||
Line 134: | Line 153: | ||
// If you're not using fastcgi, PLEASE don't keep the remote | // If you're not using fastcgi, PLEASE don't keep the remote | ||
// server waiting while you do your event processing. Remember | // server waiting while you do your event processing. Remember | ||
− | // you must respond within | + | // you must respond within 3 seconds. |
// Run a script via a cron job to process your incoming events. | // Run a script via a cron job to process your incoming events. | ||
Line 157: | Line 176: | ||
} | } | ||
} | } | ||
+ | </syntaxhighlight> | ||
+ | |||
+ | |||
+ | == Discord == | ||
+ | |||
+ | Would you like to see your CasperVend and Marketplace sales appear in your Discord server? Here's an example script! | ||
+ | |||
+ | <syntaxhighlight lang="php" line='line'> | ||
+ | <?php | ||
+ | define('WEBHOOK_SALT', '{{YOUR_WEBHOOK_SALT}}'); | ||
+ | define('DISCORD_WEBHOOK_URL', '{{YOUR_DISCORD_WEBHOOK_URL}}'); | ||
+ | define('ENABLE_DEBUG', false); | ||
+ | define('DEBUG_EMAIL', 'your@email.address'); | ||
+ | |||
+ | // Get the raw POST data | ||
+ | $postData = file_get_contents("php://input"); | ||
+ | |||
+ | // Integrity check | ||
+ | if (!isset($_SERVER['HTTP_X_CASPER_WEBHOOK_INTEGRITY_HASH']) || $_SERVER['HTTP_X_CASPER_WEBHOOK_INTEGRITY_HASH'] !== sha1($postData) || !isset($_SERVER['HTTP_X_CASPER_WEBHOOK_VERIFY_HASH'])) | ||
+ | { | ||
+ | http_response_code(400); | ||
+ | die('Corruption detected'); | ||
+ | } | ||
+ | |||
+ | // Now we check if the request is actually intended for us | ||
+ | |||
+ | $hashCheck = sha1(sha1($postData).":".WEBHOOK_SALT); | ||
+ | |||
+ | if ($_SERVER['HTTP_X_CASPER_WEBHOOK_VERIFY_HASH'] !== $hashCheck) | ||
+ | { | ||
+ | http_response_code(403); | ||
+ | if (ENABLE_DEBUG) mail(DEBUG_EMAIL, 'Webhook Script Error', 'Unauthorised request'); | ||
+ | die('Unauthorised'); | ||
+ | |||
+ | } | ||
+ | |||
+ | // By this stage we have a validated webhook event. Now decode the json | ||
+ | |||
+ | $data = json_decode($postData, true); | ||
+ | |||
+ | if ($data === false) | ||
+ | { | ||
+ | // JSON decode failure. This should never happen since the payload is signed.. | ||
+ | if (ENABLE_DEBUG) mail(DEBUG_EMAIL, 'Webhook Script Error', 'JSON decode failure'); | ||
+ | die('Invalid payload'); | ||
+ | } | ||
+ | |||
+ | |||
+ | |||
+ | echo "OK"; | ||
+ | if (function_exists('fastcgi_finish_request')) | ||
+ | { | ||
+ | // This releases the request so Casper's servers don't have to wait for discord | ||
+ | fastcgi_finish_request(); | ||
+ | } | ||
+ | |||
+ | $timestamp = date("c", strtotime("now")); | ||
+ | |||
+ | $embed = []; | ||
+ | |||
+ | $eventType = $data['metadata']['eventType']; | ||
+ | if ($eventType === 'vendor_sale') | ||
+ | { | ||
+ | if ($data['event']['flags']['luckyChair'] === TRUE) | ||
+ | { | ||
+ | $embed = [ | ||
+ | "title" => $data['event']['avatars']['purchaser']['name'] . ' won a ' . $data['event']['product']['productName'] . ' from a Lucky Chair!', | ||
+ | "color" => 15746887 | ||
+ | ]; | ||
+ | } | ||
+ | else | ||
+ | { | ||
+ | if ($data['event']['flags']['midnightMadness'] === TRUE) | ||
+ | { | ||
+ | $embed = [ | ||
+ | "title" => $data['event']['avatars']['purchaser']['name'] . ' won a ' . $data['event']['product']['productName'] . ' from a Midnight Madness board!', | ||
+ | "color" => 16426522 | ||
+ | ]; | ||
+ | } | ||
+ | else | ||
+ | { | ||
+ | if ($data['event']['flags']['gatcha'] === TRUE) | ||
+ | { | ||
+ | $embed = [ | ||
+ | "title" => $data['event']['avatars']['purchaser']['name'] . ' won a ' . $data['event']['product']['productName'] . ' from a gacha!', | ||
+ | "color" => 4437377 | ||
+ | ]; | ||
+ | } | ||
+ | else | ||
+ | { | ||
+ | $embed = [ | ||
+ | "title" => $data['event']['avatars']['purchaser']['name'] . ' bought a ' . $data['event']['product']['productName'], | ||
+ | "color" => 7506394 | ||
+ | ]; | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | |||
+ | $embed['image'] = [ | ||
+ | "url" => "https://caspervend.casperdns.com/img.php?u=".$data['event']['product']['texture']."&g=SLIFE" | ||
+ | ]; | ||
+ | |||
+ | $embed['description'] = '**Paid:** L$' . $data['event']['money']['gross']. "\n". | ||
+ | '**Received:** L$' . $data['event']['money']['received']. "\n". | ||
+ | '**Location:** ' . $data['event']['vendor']['location']. "\n"; | ||
+ | |||
+ | if ($data['event']['avatars']['recipient']['uuid'] !== $data['event']['avatars']['purchaser']['uuid']) | ||
+ | { | ||
+ | $embed['description'] = "**As a gift for " . $data['event']['avatars']['recipient']['name'] . "**\n" . $embed['description']; | ||
+ | } | ||
+ | |||
+ | if ($data['event']['flags']['giftCard'] === true || $data['event']['flags']['giftCardV3'] === true) { | ||
+ | $embed['description'] .= "*This was a gift card purchase*\n"; | ||
+ | } | ||
+ | } | ||
+ | else if ($eventType === 'marketplace_sale') | ||
+ | { | ||
+ | $embed = [ | ||
+ | "title" => $data['event']['PayerName'] . ' bought a ' . $data['event']['ItemName'] . ' from the Marketplace', | ||
+ | "color" => 814798, | ||
+ | "description" => "**Paid** L$" .$data['event']['PaymentGross']. "\n**Fee** L$" . $data['event']['PaymentFee'] | ||
+ | ]; | ||
+ | |||
+ | if ($data['event']['ReceiverKey'] !== $data['event']['PayerKey']) | ||
+ | { | ||
+ | $embed['description'] = "**As a gift for " . $data['event']['ReceiverName'] . "**\n" . $embed['description']; | ||
+ | } | ||
+ | } | ||
+ | else | ||
+ | { | ||
+ | $embed = [ | ||
+ | "title" => 'Received unsupported event of type ' . $eventType, | ||
+ | "color" => 7506394 | ||
+ | ]; | ||
+ | } | ||
+ | |||
+ | |||
+ | $json_data = json_encode([ | ||
+ | "username" => "Store", | ||
+ | |||
+ | "embeds" => [ | ||
+ | $embed | ||
+ | ] | ||
+ | |||
+ | ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); | ||
+ | |||
+ | |||
+ | $ch = curl_init( DISCORD_WEBHOOK_URL ); | ||
+ | curl_setopt( $ch, CURLOPT_HTTPHEADER, array('Content-type: application/json')); | ||
+ | curl_setopt( $ch, CURLOPT_POST, 1); | ||
+ | curl_setopt( $ch, CURLOPT_POSTFIELDS, $json_data); | ||
+ | curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, 1); | ||
+ | curl_setopt( $ch, CURLOPT_HEADER, 0); | ||
+ | curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1); | ||
+ | |||
+ | $response = curl_exec( $ch ); | ||
+ | |||
+ | if (ENABLE_DEBUG) | ||
+ | { | ||
+ | mail(DEBUG_EMAIL, 'Discord Webhook Response', $response); | ||
+ | } | ||
+ | |||
+ | curl_close( $ch ); | ||
</syntaxhighlight> | </syntaxhighlight> |
Latest revision as of 08:52, 20 July 2021
New for 2018, our Webhook system is a beefed-up version of CasperVend's ANS notification system. This is an advanced system which requires programming knowledge. It's designed to reliably send events to server(s) under your control.
Benefits
- * Increased reliability. If your server goes offline, we'll keep sending until it goes through (though see the caveats below)
- * Not just for vendor sales, but can also notify for marketplace sales and redeliveries
- * Not just for CasperVend, but will be rolled out for CasperLet, CasperSafe, CasperUpdate, etc. in the future.
- * JSON encoded so can be read by nearly all backend frameworks
- * In the event of a failure to reach your endpoint, we will notify your avatar by IM (after 5 attempts).
- * SL Marketplace notifications include the matched CasperVend product ID (zero if not associated).
Events Currently Tracked
- * Product purchase inworld
- * SL Marketplace purchase
Tracking Coming Soon For
- * Redeliveries
- * Update Delivery
(This list will be added to and corrected as features and additional event tracking are built in.)
Caveats
- * If we can't get through to your server, we will try again on an exponential backoff. The first 5 attempts happen every minute, but after that the length of time doubles for each attempt.
- * If we still cannot get through to your server after 43 attempts (approximately 24 hours), we will drop all pending notifications and disable your webhook.
- * Because of the retry mechanism, your code must be prepared to accept duplicate requests.
- * We send events in date order, so if an early event fails, you won't receive any later events until your endpoint responds correctly.
- * Your endpoint must respond within 3 seconds.
Getting Help
This is an advanced feature; you are expected to know how to write and debug your own scripts, so we won't help you do that.
However, if you need a different kind of help - or if you think something is wrong at our end, do a ticket to Casper. The rest of the support staff are NOT scripters and CANNOT assist you with this.
Example Code
MySQL
Should work with any modern MySQL daemon. Tested with MariaDB 10.2.12
1 CREATE DATABASE IF NOT EXISTS `webhooks`;
2 USE `webhooks`;
3
4 CREATE TABLE IF NOT EXISTS `events` (
5 `eventID` char(36) NOT NULL,
6 `received` datetime NOT NULL DEFAULT current_timestamp(),
7 `processed` tinyint(1) unsigned NOT NULL DEFAULT 0,
8 `data` mediumblob DEFAULT NULL,
9 PRIMARY KEY (`eventID`),
10 KEY `processed` (`processed`)
11 ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
PHP
This code assumes that you have a database created using the SQL above.
1 <?php
2
3 // Configuration options
4
5 define('WEBHOOK_SALT', 'YOUR SALT HERE');
6
7 define('ENABLE_DEBUG', true);
8 define('DEBUG_EMAIL', 'your@email.address');
9
10 define('DATABASE_HOST', '127.0.0.1');
11 define('DATABASE_USER', 'webhooks');
12 define('DATABASE_PASSWORD', 'somepassword');
13 define('DATABASE_DATABASE', 'webhooks');
14
15 // Get the raw POST data
16 $postData = file_get_contents("php://input");
17
18 // Integrity check
19 if (!isset($_SERVER['HTTP_X_CASPER_WEBHOOK_INTEGRITY_HASH']) || $_SERVER['HTTP_X_CASPER_WEBHOOK_INTEGRITY_HASH'] !== sha1($postData) || !isset($_SERVER['HTTP_X_CASPER_WEBHOOK_VERIFY_HASH']))
20 {
21 http_response_code(400);
22 die('Corruption detected');
23 }
24
25 // Connect to the database
26 $dsn = 'mysql:dbname='.DATABASE_DATABASE.';host='.DATABASE_HOST;
27
28 try
29 {
30 $dbh = new PDO($dsn, DATABASE_USER, DATABASE_PASSWORD);
31 } catch (PDOException $e)
32 {
33 if (ENABLE_DEBUG) mail(DEBUG_EMAIL, 'Webhook Script Error', 'Database connection failed');
34 die('Database connection failed');
35 }
36
37 // Now we check if the request is actually intended for us
38
39 $hashCheck = sha1(sha1($postData).":".WEBHOOK_SALT);
40
41 if ($_SERVER['HTTP_X_CASPER_WEBHOOK_VERIFY_HASH'] !== $hashCheck)
42 {
43 http_response_code(403);
44 if (ENABLE_DEBUG) mail(DEBUG_EMAIL, 'Webhook Script Error', 'Unauthorised request');
45 die('Unauthorised');
46 }
47
48 // By this stage we have a validated webhook event. Now decode the json
49
50 $data = json_decode($postData, true);
51
52 if ($data === false)
53 {
54 // JSON decode failure. This should never happen since the payload is signed..
55 if (ENABLE_DEBUG) mail(DEBUG_EMAIL, 'Webhook Script Error', 'JSON decode failure');
56 die('Invalid payload');
57 }
58
59
60 // Get the event ID
61 $eventID = $data['metadata']['eventID'];
62
63 // Insert the event into the database.
64
65 // IMPORTANT: To avoid duplicates, make sure that eventID is a primary key.
66
67 $stmt = $dbh->prepare("INSERT IGNORE INTO `events` (`eventID`, `received`, `data`) VALUES (:eventID, NOW(), :data)");
68
69 if (!$stmt)
70 {
71 if (ENABLE_DEBUG) mail(DEBUG_EMAIL, 'Database error', var_export($dbh->errorInfo(), true));
72 }
73
74 if (!$stmt->execute([
75 ":eventID" => $eventID,
76 ":data" => $postData
77 ]))
78 {
79 if (ENABLE_DEBUG) mail(DEBUG_EMAIL, 'Database statement error', var_export($stmt->errorInfo(), true));
80 }
81
82
83 if ($stmt->rowCount() == 0)
84 {
85 if (ENABLE_DEBUG) mail(DEBUG_EMAIL,'duplicate ans event', $postData);
86 // Duplicate event
87 die();
88 }
89 else
90 {
91 if (!function_exists('fastcgi_finish_request'))
92 {
93 // If you're not using fastcgi, PLEASE don't keep the remote
94 // server waiting while you do your event processing. Remember
95 // you must respond within 3 seconds.
96
97 // Run a script via a cron job to process your incoming events.
98
99 die();
100 }
101 fastcgi_finish_request();
102
103
104 // Process any unprocessed events. You can do this here, or offload it into a cron job or something.
105
106 $stmt = $dbh->query("SELECT `data`,`eventID` FROM `events` WHERE `processed` = 0 ORDER BY `received` ASC");
107 while($row = $stmt->fetch(\PDO::FETCH_ASSOC))
108 {
109 // Do your processing
110
111 // blah blah blah
112
113 // All done? Mark the event as processed
114 $substmt = $dbh->prepare("UPDATE `events` SET `processed` = 1 WHERE `eventID` = :id");
115 $substmt->execute([":id" => $row['eventID']]);
116 }
117 }
Discord
Would you like to see your CasperVend and Marketplace sales appear in your Discord server? Here's an example script!
1 <?php
2 define('WEBHOOK_SALT', '{{YOUR_WEBHOOK_SALT}}');
3 define('DISCORD_WEBHOOK_URL', '{{YOUR_DISCORD_WEBHOOK_URL}}');
4 define('ENABLE_DEBUG', false);
5 define('DEBUG_EMAIL', 'your@email.address');
6
7 // Get the raw POST data
8 $postData = file_get_contents("php://input");
9
10 // Integrity check
11 if (!isset($_SERVER['HTTP_X_CASPER_WEBHOOK_INTEGRITY_HASH']) || $_SERVER['HTTP_X_CASPER_WEBHOOK_INTEGRITY_HASH'] !== sha1($postData) || !isset($_SERVER['HTTP_X_CASPER_WEBHOOK_VERIFY_HASH']))
12 {
13 http_response_code(400);
14 die('Corruption detected');
15 }
16
17 // Now we check if the request is actually intended for us
18
19 $hashCheck = sha1(sha1($postData).":".WEBHOOK_SALT);
20
21 if ($_SERVER['HTTP_X_CASPER_WEBHOOK_VERIFY_HASH'] !== $hashCheck)
22 {
23 http_response_code(403);
24 if (ENABLE_DEBUG) mail(DEBUG_EMAIL, 'Webhook Script Error', 'Unauthorised request');
25 die('Unauthorised');
26
27 }
28
29 // By this stage we have a validated webhook event. Now decode the json
30
31 $data = json_decode($postData, true);
32
33 if ($data === false)
34 {
35 // JSON decode failure. This should never happen since the payload is signed..
36 if (ENABLE_DEBUG) mail(DEBUG_EMAIL, 'Webhook Script Error', 'JSON decode failure');
37 die('Invalid payload');
38 }
39
40
41
42 echo "OK";
43 if (function_exists('fastcgi_finish_request'))
44 {
45 // This releases the request so Casper's servers don't have to wait for discord
46 fastcgi_finish_request();
47 }
48
49 $timestamp = date("c", strtotime("now"));
50
51 $embed = [];
52
53 $eventType = $data['metadata']['eventType'];
54 if ($eventType === 'vendor_sale')
55 {
56 if ($data['event']['flags']['luckyChair'] === TRUE)
57 {
58 $embed = [
59 "title" => $data['event']['avatars']['purchaser']['name'] . ' won a ' . $data['event']['product']['productName'] . ' from a Lucky Chair!',
60 "color" => 15746887
61 ];
62 }
63 else
64 {
65 if ($data['event']['flags']['midnightMadness'] === TRUE)
66 {
67 $embed = [
68 "title" => $data['event']['avatars']['purchaser']['name'] . ' won a ' . $data['event']['product']['productName'] . ' from a Midnight Madness board!',
69 "color" => 16426522
70 ];
71 }
72 else
73 {
74 if ($data['event']['flags']['gatcha'] === TRUE)
75 {
76 $embed = [
77 "title" => $data['event']['avatars']['purchaser']['name'] . ' won a ' . $data['event']['product']['productName'] . ' from a gacha!',
78 "color" => 4437377
79 ];
80 }
81 else
82 {
83 $embed = [
84 "title" => $data['event']['avatars']['purchaser']['name'] . ' bought a ' . $data['event']['product']['productName'],
85 "color" => 7506394
86 ];
87 }
88 }
89 }
90
91 $embed['image'] = [
92 "url" => "https://caspervend.casperdns.com/img.php?u=".$data['event']['product']['texture']."&g=SLIFE"
93 ];
94
95 $embed['description'] = '**Paid:** L$' . $data['event']['money']['gross']. "\n".
96 '**Received:** L$' . $data['event']['money']['received']. "\n".
97 '**Location:** ' . $data['event']['vendor']['location']. "\n";
98
99 if ($data['event']['avatars']['recipient']['uuid'] !== $data['event']['avatars']['purchaser']['uuid'])
100 {
101 $embed['description'] = "**As a gift for " . $data['event']['avatars']['recipient']['name'] . "**\n" . $embed['description'];
102 }
103
104 if ($data['event']['flags']['giftCard'] === true || $data['event']['flags']['giftCardV3'] === true) {
105 $embed['description'] .= "*This was a gift card purchase*\n";
106 }
107 }
108 else if ($eventType === 'marketplace_sale')
109 {
110 $embed = [
111 "title" => $data['event']['PayerName'] . ' bought a ' . $data['event']['ItemName'] . ' from the Marketplace',
112 "color" => 814798,
113 "description" => "**Paid** L$" .$data['event']['PaymentGross']. "\n**Fee** L$" . $data['event']['PaymentFee']
114 ];
115
116 if ($data['event']['ReceiverKey'] !== $data['event']['PayerKey'])
117 {
118 $embed['description'] = "**As a gift for " . $data['event']['ReceiverName'] . "**\n" . $embed['description'];
119 }
120 }
121 else
122 {
123 $embed = [
124 "title" => 'Received unsupported event of type ' . $eventType,
125 "color" => 7506394
126 ];
127 }
128
129
130 $json_data = json_encode([
131 "username" => "Store",
132
133 "embeds" => [
134 $embed
135 ]
136
137 ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
138
139
140 $ch = curl_init( DISCORD_WEBHOOK_URL );
141 curl_setopt( $ch, CURLOPT_HTTPHEADER, array('Content-type: application/json'));
142 curl_setopt( $ch, CURLOPT_POST, 1);
143 curl_setopt( $ch, CURLOPT_POSTFIELDS, $json_data);
144 curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, 1);
145 curl_setopt( $ch, CURLOPT_HEADER, 0);
146 curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1);
147
148 $response = curl_exec( $ch );
149
150 if (ENABLE_DEBUG)
151 {
152 mail(DEBUG_EMAIL, 'Discord Webhook Response', $response);
153 }
154
155 curl_close( $ch );